diff --git a/.editorconfig b/.editorconfig index 200de86ef..6d48180b0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,485 +1,1315 @@ -# Remove the line below if you want to inherit .editorconfig settings from higher directories +############################### +# Core EditorConfig Options # +############################### root = true +# All files +[*] +indent_style = space +end_of_line = crlf -# C# files -[*.cs] +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 -#### Core EditorConfig Options #### +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 -# Indentation and spacing +# Code files +[*.{cs,csx,vb,vbx}] indent_size = 4 -indent_style = space -tab_width = 4 - -# New line preferences -end_of_line = crlf insert_final_newline = false - -#### .NET Coding Conventions #### - +charset = utf-8-bom +trim_trailing_whitespace = true +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] # Organize usings -dotnet_separate_import_directive_groups = false dotnet_sort_system_directives_first = true -file_header_template = unset - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:warning -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_property = false:warning - +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:warning -dotnet_style_predefined_type_for_member_access = true:warning - +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning - +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members - +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion # Expression-level preferences -dotnet_style_coalesce_expression = true:warning -dotnet_style_collection_initializer = true:warning -dotnet_style_explicit_tuple_names = true:warning -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true:warning -dotnet_style_object_initializer = true:warning -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true:warning -dotnet_style_prefer_compound_assignment = true:warning -dotnet_style_prefer_conditional_expression_over_assignment = true:warning -dotnet_style_prefer_conditional_expression_over_return = true:warning -dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning -dotnet_style_prefer_inferred_tuple_names = true:warning -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning -dotnet_style_prefer_simplified_boolean_expressions = true:warning -dotnet_style_prefer_simplified_interpolation = true - -# Field preferences -dotnet_style_readonly_field = true:warning - -# Parameter preferences -dotnet_code_quality_unused_parameters = all:warning - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = 0 - -# New line preferences -dotnet_style_allow_multiple_blank_lines_experimental = false:warning -dotnet_style_allow_statement_immediately_after_block_experimental = false:warning - -#### C# Coding Conventions #### - +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +############################### +# Naming Conventions # +############################### +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const +############################### +# C# Coding Conventions # +############################### +[*.cs] # var preferences -csharp_style_var_elsewhere = false:warning -csharp_style_var_for_built_in_types = false:warning -csharp_style_var_when_type_is_apparent = true:warning - +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = false:silent # Expression-bodied members -csharp_style_expression_bodied_accessors = true:none -csharp_style_expression_bodied_constructors = false:none -csharp_style_expression_bodied_indexers = true:none -csharp_style_expression_bodied_lambdas = true:none -csharp_style_expression_bodied_local_functions = false:none -csharp_style_expression_bodied_methods = false:none -csharp_style_expression_bodied_operators = false:none -csharp_style_expression_bodied_properties = true:none - +csharp_style_expression_bodied_methods = when_on_single_line:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent # Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:warning -csharp_style_pattern_matching_over_is_with_cast_check = true:warning -csharp_style_prefer_extended_property_pattern = true:warning -csharp_style_prefer_not_pattern = true:warning -csharp_style_prefer_pattern_matching = true:warning -csharp_style_prefer_switch_expression = true:warning - +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion # Null-checking preferences -csharp_style_conditional_delegate_call = true:warning -csharp_style_prefer_parameter_null_checking = true:warning - +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences -csharp_prefer_static_local_function = true:suggestion -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async - -# Code-block preferences -csharp_prefer_braces = when_multiline:warning -csharp_prefer_simple_using_statement = true:warning -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:warning - +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion # Expression-level preferences -csharp_prefer_simple_default_expression = true:warning -csharp_style_deconstructed_variable_declaration = true:warning -csharp_style_implicit_object_creation_when_type_is_apparent = true:warning -csharp_style_inlined_variable_declaration = true:warning -csharp_style_prefer_index_operator = true:warning -csharp_style_prefer_local_over_anonymous_function = true:warning -csharp_style_prefer_null_check_over_type_check = true:warning -csharp_style_prefer_range_operator = true:warning -csharp_style_prefer_tuple_swap = true:warning -csharp_style_throw_expression = true:warning -csharp_style_unused_value_assignment_preference = discard_variable:warning -csharp_style_unused_value_expression_statement_preference = discard_variable:warning - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:warning - -# New line preferences -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:suggestion -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion -csharp_style_allow_embedded_statements_on_same_line_experimental = false:warning - -#### C# Formatting Rules #### - +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +############################### +# C# Formatting Rules # +############################### # New line preferences -csharp_new_line_before_catch = true +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_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all +csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = 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_labels = no_change csharp_indent_switch_labels = true - +csharp_indent_labels = flush_left # 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 - +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences -csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +############################### +# VB Coding Conventions # +############################### +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion -#### Naming styles #### - -# Naming rules - -dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i - -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.symbols = types -dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case +[*.cs] -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = silent -# Symbol specifications +# IDE0033: Use explicitly provided tuple name +dotnet_diagnostic.IDE0033.severity = warning -dotnet_naming_symbols.interface.applicable_kinds = interface -dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = +# IDE0070: Use 'System.HashCode' +dotnet_diagnostic.IDE0070.severity = warning -dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum -dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = +# IDE0004: Remove Unnecessary Cast +dotnet_diagnostic.IDE0004.severity = warning -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +# IDE0005: Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = warning -# Naming styles +# IDE0007: Use implicit type +dotnet_diagnostic.IDE0007.severity = warning -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case +# IDE0008: Use explicit type +dotnet_diagnostic.IDE0008.severity = warning -dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = -dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_diagnostic.IDE0005.severity = warning -dotnet_diagnostic.IDE0063.severity = warning -dotnet_diagnostic.IDE0065.severity = warning -dotnet_diagnostic.CA1200.severity = warning -dotnet_diagnostic.IDE0051.severity = warning -dotnet_diagnostic.IDE0052.severity = warning -dotnet_diagnostic.IDE0064.severity = suggestion -dotnet_diagnostic.IDE0076.severity = warning -dotnet_diagnostic.IDE0077.severity = warning -dotnet_diagnostic.IDE0043.severity = warning -dotnet_diagnostic.CA1070.severity = warning -dotnet_diagnostic.CA1001.severity = warning -dotnet_diagnostic.CA1309.severity = warning -dotnet_diagnostic.CA1507.severity = warning -dotnet_diagnostic.CA1805.severity = warning -dotnet_diagnostic.CA1824.severity = warning -dotnet_diagnostic.CA1825.severity = warning -dotnet_diagnostic.CA1841.severity = warning -dotnet_diagnostic.CA1845.severity = warning -dotnet_diagnostic.CA2016.severity = warning -dotnet_diagnostic.IDE0004.severity = warning -dotnet_diagnostic.IDE0007.severity = warning -dotnet_diagnostic.IDE0008.severity = silent +# IDE0009: Member access should be qualified. dotnet_diagnostic.IDE0009.severity = warning -dotnet_diagnostic.IDE0010.severity = none + +# IDE0011: Add braces dotnet_diagnostic.IDE0011.severity = warning + +# IDE0016: Use 'throw' expression dotnet_diagnostic.IDE0016.severity = warning + +# IDE0017: Simplify object initialization dotnet_diagnostic.IDE0017.severity = warning + +# IDE0018: Inline variable declaration dotnet_diagnostic.IDE0018.severity = warning + +# IDE0019: Use pattern matching dotnet_diagnostic.IDE0019.severity = warning + +# IDE0020: Use pattern matching dotnet_diagnostic.IDE0020.severity = warning -dotnet_diagnostic.IDE0021.severity = none -dotnet_diagnostic.IDE0022.severity = none -dotnet_diagnostic.IDE0023.severity = none -dotnet_diagnostic.IDE0024.severity = none -dotnet_diagnostic.IDE0025.severity = none -dotnet_diagnostic.IDE0026.severity = none -dotnet_diagnostic.IDE0027.severity = none + +# IDE0021: Use expression body for constructor +dotnet_diagnostic.IDE0021.severity = warning + +# IDE0022: Use expression body for method +dotnet_diagnostic.IDE0022.severity = warning + +# IDE0023: Use expression body for conversion operator +dotnet_diagnostic.IDE0023.severity = warning + +# IDE0024: Use expression body for operator +dotnet_diagnostic.IDE0024.severity = warning + +# IDE0025: Use expression body for property +dotnet_diagnostic.IDE0025.severity = warning + +# IDE0026: Use expression body for indexer +dotnet_diagnostic.IDE0026.severity = warning + +# IDE0027: Use expression body for accessor +dotnet_diagnostic.IDE0027.severity = warning + +# IDE0028: Simplify collection initialization dotnet_diagnostic.IDE0028.severity = warning + +# IDE0029: Use coalesce expression dotnet_diagnostic.IDE0029.severity = warning + +# IDE0030: Use coalesce expression dotnet_diagnostic.IDE0030.severity = warning + +# IDE0031: Use null propagation dotnet_diagnostic.IDE0031.severity = warning -dotnet_diagnostic.IDE0032.severity = warning + +# IDE0034: Simplify 'default' expression dotnet_diagnostic.IDE0034.severity = warning + +# IDE0032: Use auto property +dotnet_diagnostic.IDE0032.severity = warning + +# IDE0036: Order modifiers dotnet_diagnostic.IDE0036.severity = warning + +# IDE0037: Use inferred member name dotnet_diagnostic.IDE0037.severity = warning + +# IDE0039: Use local function dotnet_diagnostic.IDE0039.severity = warning + +# IDE0040: Add accessibility modifiers dotnet_diagnostic.IDE0040.severity = warning + +# IDE0041: Use 'is null' check dotnet_diagnostic.IDE0041.severity = warning -dotnet_diagnostic.IDE0042.severity = warning -dotnet_diagnostic.IDE0045.severity = suggestion -dotnet_diagnostic.IDE0046.severity = none + +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning + +# IDE0045: Convert to conditional expression +dotnet_diagnostic.IDE0045.severity = warning + +# IDE0046: Convert to conditional expression +dotnet_diagnostic.IDE0046.severity = warning + +# IDE0047: Remove unnecessary parentheses dotnet_diagnostic.IDE0047.severity = warning + +# IDE0043: Invalid format string +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0048: Add parentheses for clarity dotnet_diagnostic.IDE0048.severity = warning -dotnet_diagnostic.IDE0055.severity = warning + +# IDE0053: Use block body for lambda expression +dotnet_diagnostic.IDE0053.severity = warning + +# IDE0054: Use compound assignment dotnet_diagnostic.IDE0054.severity = warning + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# IDE0056: Use index operator dotnet_diagnostic.IDE0056.severity = warning -dotnet_diagnostic.IDE0057.severity = warning -dotnet_diagnostic.IDE0058.severity = warning -dotnet_diagnostic.IDE0060.severity = warning -dotnet_diagnostic.IDE0066.severity = warning + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = suggestion + +# IDE0059: Unnecessary assignment of a value dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = warning + +# IDE0061: Use expression body for local function dotnet_diagnostic.IDE0061.severity = warning + +# IDE0062: Make local function 'static' dotnet_diagnostic.IDE0062.severity = warning + +# IDE0063: Use simple 'using' statement +dotnet_diagnostic.IDE0063.severity = warning + +# IDE0064: Make readonly fields writable +dotnet_diagnostic.IDE0064.severity = warning + +# IDE0065: Misplaced using directive +dotnet_diagnostic.IDE0065.severity = warning + +# IDE0066: Convert switch statement to expression +dotnet_diagnostic.IDE0066.severity = warning + +# IDE0071: Simplify interpolation dotnet_diagnostic.IDE0071.severity = warning -dotnet_diagnostic.IDE0072.severity = warning -dotnet_diagnostic.IDE0073.severity = none + +# IDE0073: The file header does not match the required text +dotnet_diagnostic.IDE0073.severity = warning + +# IDE0074: Use compound assignment dotnet_diagnostic.IDE0074.severity = warning + +# IDE0075: Simplify conditional expression dotnet_diagnostic.IDE0075.severity = warning + +# IDE0076: Invalid global 'SuppressMessageAttribute' +dotnet_diagnostic.IDE0076.severity = warning + +# IDE0077: Avoid legacy format target in 'SuppressMessageAttribute' +dotnet_diagnostic.IDE0077.severity = warning + +# IDE0078: Use pattern matching dotnet_diagnostic.IDE0078.severity = warning + +# IDE0080: Remove unnecessary suppression operator dotnet_diagnostic.IDE0080.severity = warning + +# IDE0082: 'typeof' can be converted to 'nameof' dotnet_diagnostic.IDE0082.severity = warning + +# IDE0083: Use pattern matching dotnet_diagnostic.IDE0083.severity = warning -dotnet_diagnostic.IDE0090.severity = suggestion -dotnet_diagnostic.IDE0100.severity = warning + +# IDE0090: Use 'new(...)' +dotnet_diagnostic.IDE0090.severity = warning + +# IDE0110: Remove unnecessary discard dotnet_diagnostic.IDE0110.severity = warning + +# IDE0120: Simplify LINQ expression dotnet_diagnostic.IDE0120.severity = warning -dotnet_diagnostic.IDE0130.severity = none + +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = warning + +# IDE0150: Prefer 'null' check over type check dotnet_diagnostic.IDE0150.severity = warning + +# IDE0160: Convert to block scoped namespace dotnet_diagnostic.IDE0160.severity = warning -dotnet_diagnostic.IDE0161.severity = suggestion + +# IDE0170: Property pattern can be simplified dotnet_diagnostic.IDE0170.severity = warning -dotnet_diagnostic.IDE0190.severity = warning + +# IDE0180: Use tuple to swap values dotnet_diagnostic.IDE0180.severity = warning + +# IDE0200: Remove unnecessary lambda expression dotnet_diagnostic.IDE0200.severity = warning + +# IDE0210: Convert to top-level statements +dotnet_diagnostic.IDE0210.severity = warning + +# IDE0211: Convert to 'Program.Main' style program +dotnet_diagnostic.IDE0211.severity = warning + +# IDE0230: Use UTF-8 string literal +dotnet_diagnostic.IDE0230.severity = warning + +# IDE0240: Remove redundant nullable directive +dotnet_diagnostic.IDE0240.severity = warning + +# IDE0241: Remove unnecessary nullable directive +dotnet_diagnostic.IDE0241.severity = warning + +# IDE0250: Make struct 'readonly' +dotnet_diagnostic.IDE0250.severity = warning + +# IDE0260: Use pattern matching +dotnet_diagnostic.IDE0260.severity = warning + +# IDE0270: Use coalesce expression +dotnet_diagnostic.IDE0270.severity = warning + +# IDE0280: Use 'nameof' +dotnet_diagnostic.IDE0280.severity = warning + +# IDE1005: Delegate invocation can be simplified. dotnet_diagnostic.IDE1005.severity = warning -dotnet_diagnostic.IDE1006.severity = warning + +# IDE2000: Avoid multiple blank lines dotnet_diagnostic.IDE2000.severity = warning + +# IDE2001: Embedded statements must be on their own line dotnet_diagnostic.IDE2001.severity = warning + +# IDE2002: Consecutive braces must not have blank line between them dotnet_diagnostic.IDE2002.severity = warning + +# IDE2003: Blank line required between block and subsequent statement dotnet_diagnostic.IDE2003.severity = warning + +# IDE2004: Blank line not allowed after constructor initializer colon dotnet_diagnostic.IDE2004.severity = warning -dotnet_diagnostic.CA2244.severity = warning -dotnet_diagnostic.CA2246.severity = warning -dotnet_diagnostic.SA1633.severity = none -dotnet_diagnostic.SA1600.severity = none -dotnet_diagnostic.SA1601.severity = none -dotnet_diagnostic.SA1602.severity = none -dotnet_diagnostic.SA1101.severity = none -dotnet_diagnostic.SA1503.severity = none -dotnet_diagnostic.SA1413.severity = none -dotnet_diagnostic.SA1210.severity = none -dotnet_diagnostic.SA0001.severity = none -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_utf8_string_literals = true:suggestion -dotnet_diagnostic.SA1623.severity = silent -dotnet_diagnostic.SA1204.severity = suggestion -dotnet_diagnostic.SA1401.severity = silent -dotnet_diagnostic.SA1303.severity = suggestion -dotnet_diagnostic.SX1309.severity = silent -dotnet_diagnostic.SX1309S.severity = silent -dotnet_diagnostic.SA1310.severity = suggestion -dotnet_diagnostic.SA1309.severity = suggestion -dotnet_diagnostic.SA1306.severity = silent -dotnet_diagnostic.SA1201.severity = silent -dotnet_diagnostic.SA1516.severity = silent -dotnet_diagnostic.SA1615.severity = silent -dotnet_diagnostic.SA1212.severity = silent -dotnet_diagnostic.SA1515.severity = silent -dotnet_diagnostic.SA1202.severity = silent -dotnet_diagnostic.SA1116.severity = silent -dotnet_diagnostic.SA1117.severity = silent -dotnet_diagnostic.SA1118.severity = suggestion -dotnet_diagnostic.SA1512.severity = silent -dotnet_diagnostic.SA1407.severity = silent -[*.{cs,vb}] -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -indent_size = 4 -end_of_line = crlf -dotnet_style_coalesce_expression = true:warning -dotnet_style_null_propagation = true:warning -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning -dotnet_style_prefer_auto_properties = true:warning -dotnet_style_object_initializer = true:warning -dotnet_style_collection_initializer = true:warning -dotnet_style_prefer_simplified_boolean_expressions = true:warning -dotnet_style_prefer_conditional_expression_over_assignment = true:warning -dotnet_style_prefer_conditional_expression_over_return = true:warning -dotnet_style_explicit_tuple_names = true:warning -dotnet_style_prefer_inferred_tuple_names = true:warning -dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning -dotnet_style_prefer_compound_assignment = true:warning -dotnet_style_prefer_simplified_interpolation = true:warning -dotnet_style_namespace_match_folder = true:none -dotnet_diagnostic.CA1834.severity = warning -dotnet_diagnostic.CA2249.severity = warning +# IDE2005: Blank line not allowed after conditional expression token +dotnet_diagnostic.IDE2005.severity = warning + +# IDE2006: Blank line not allowed after arrow expression clause token +dotnet_diagnostic.IDE2006.severity = warning + +# CA1001: Types that own disposable fields should be disposable +dotnet_diagnostic.CA1001.severity = warning + +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = silent + +# CA1200: Avoid using cref tags with a prefix +dotnet_diagnostic.CA1200.severity = warning + +# CA1311: Specify a culture or use an invariant version +dotnet_diagnostic.CA1311.severity = warning + +# CA1507: Use nameof to express symbol names +dotnet_diagnostic.CA1507.severity = warning + +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = warning + +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = warning + +# CA1824: Mark assemblies with NeutralResourcesLanguageAttribute +dotnet_diagnostic.CA1824.severity = warning + +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = warning + +# CA1841: Prefer Dictionary.Contains methods +dotnet_diagnostic.CA1841.severity = warning + +# CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = warning + +# CA1851: Possible multiple enumerations of 'IEnumerable' collection +dotnet_diagnostic.CA1851.severity = warning + +# CA1855: Prefer 'Clear' over 'Fill' +dotnet_diagnostic.CA1855.severity = warning + +# CA2014: Do not use stackalloc in loops +dotnet_diagnostic.CA2014.severity = warning + +# CA2016: Forward the 'CancellationToken' parameter to methods +dotnet_diagnostic.CA2016.severity = warning + +# CA2020: Prevent from behavioral change +dotnet_diagnostic.CA2020.severity = warning + +# CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = warning + +# CA2252: This API requires opting into preview features +dotnet_diagnostic.CA2252.severity = warning + +# CA2352: Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks +dotnet_diagnostic.CA2352.severity = warning + +# CA2353: Unsafe DataSet or DataTable in serializable type +dotnet_diagnostic.CA2353.severity = warning + +# CA2354: Unsafe DataSet or DataTable in deserialized object graph can be vulnerable to remote code execution attacks +dotnet_diagnostic.CA2354.severity = warning + +# CA2355: Unsafe DataSet or DataTable type found in deserializable object graph +dotnet_diagnostic.CA2355.severity = warning + +# CA2362: Unsafe DataSet or DataTable in auto-generated serializable type can be vulnerable to remote code execution attacks +dotnet_diagnostic.CA2362.severity = warning + +# CA1812: Avoid uninstantiated internal classes +dotnet_diagnostic.CA1812.severity = warning + +# CA1309: Use ordinal string comparison +dotnet_diagnostic.CA1309.severity = warning + +# CA2356: Unsafe DataSet or DataTable type in web deserializable object graph +dotnet_diagnostic.CA2356.severity = warning + +# IDE1006: Naming Styles +dotnet_diagnostic.IDE1006.severity = warning + +# IDE0220: Add explicit cast +dotnet_diagnostic.IDE0220.severity = warning + +# IDE0161: Convert to file-scoped namespace +dotnet_diagnostic.IDE0161.severity = warning + +# IDE0100: Remove redundant equality +dotnet_diagnostic.IDE0100.severity = warning + +# IDE0072: Add missing cases +dotnet_diagnostic.IDE0072.severity = warning + +# IDE0057: Use range operator +dotnet_diagnostic.IDE0057.severity = warning + +# IDE0042: Deconstruct variable declaration +dotnet_diagnostic.IDE0042.severity = warning + +# IDE0051: Remove unused private members +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0052: Remove unread private members +dotnet_diagnostic.IDE0052.severity = warning + +# IDE0010: Add missing cases +dotnet_diagnostic.IDE0010.severity = suggestion + +# CA1000: Do not declare static members on generic types dotnet_diagnostic.CA1000.severity = warning + +# CA1002: Do not expose generic lists +dotnet_diagnostic.CA1002.severity = warning + +# CA1003: Use generic event handler instances +dotnet_diagnostic.CA1003.severity = warning + +# CA1005: Avoid excessive parameters on generic types +dotnet_diagnostic.CA1005.severity = warning + +# CA1008: Enums should have zero value +dotnet_diagnostic.CA1008.severity = silent + +# CA1010: Generic interface should also be implemented dotnet_diagnostic.CA1010.severity = warning -dotnet_diagnostic.CA1016.severity = warning -dotnet_diagnostic.CA1018.severity = warning + +# CA1014: Mark assemblies with CLSCompliant +dotnet_diagnostic.CA1014.severity = warning + +# CA1012: Abstract types should not have public constructors +dotnet_diagnostic.CA1012.severity = warning + +# CA1016: Mark assemblies with assembly version +dotnet_diagnostic.CA1016.severity = warning + +# CA1017: Mark assemblies with ComVisible +dotnet_diagnostic.CA1017.severity = warning + +# CA1019: Define accessors for attribute arguments +dotnet_diagnostic.CA1019.severity = warning + +# CA1021: Avoid out parameters +dotnet_diagnostic.CA1021.severity = warning + +# CA1024: Use properties where appropriate +dotnet_diagnostic.CA1024.severity = warning + +# CA1027: Mark enums with FlagsAttribute +dotnet_diagnostic.CA1027.severity = warning + +# CA1030: Use events where appropriate +dotnet_diagnostic.CA1030.severity = warning + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = suggestion + +# CA1033: Interface methods should be callable by child types +dotnet_diagnostic.CA1033.severity = warning + +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = warning + +# CA1036: Override methods on comparable types +dotnet_diagnostic.CA1036.severity = warning + +# CA1040: Avoid empty interfaces +dotnet_diagnostic.CA1040.severity = warning + +# CA1041: Provide ObsoleteAttribute message dotnet_diagnostic.CA1041.severity = warning + +# CA1043: Use Integral Or String Argument For Indexers +dotnet_diagnostic.CA1043.severity = warning + +# CA1045: Do not pass types by reference +dotnet_diagnostic.CA1045.severity = warning + +# CA1046: Do not overload equality operator on reference types +dotnet_diagnostic.CA1046.severity = warning + +# CA1050: Declare types in namespaces dotnet_diagnostic.CA1050.severity = warning -dotnet_diagnostic.CA1051.severity = silent + +# CA1052: Static holder types should be Static or NotInheritable +dotnet_diagnostic.CA1052.severity = warning + +# CA1054: URI-like parameters should not be strings +dotnet_diagnostic.CA1054.severity = warning + +# CA1055: URI-like return values should not be strings +dotnet_diagnostic.CA1055.severity = warning + +# CA1058: Types should not extend certain base types +dotnet_diagnostic.CA1058.severity = warning + +# CA1060: Move pinvokes to native methods class +dotnet_diagnostic.CA1060.severity = warning + +# CA1061: Do not hide base class methods dotnet_diagnostic.CA1061.severity = warning -dotnet_diagnostic.CA1067.severity = warning + +# CA1063: Implement IDisposable Correctly +dotnet_diagnostic.CA1063.severity = warning + +# CA1065: Do not raise exceptions in unexpected locations +dotnet_diagnostic.CA1065.severity = warning + +# CA1066: Implement IEquatable when overriding Object.Equals +dotnet_diagnostic.CA1066.severity = warning + +# CA1068: CancellationToken parameters must come last dotnet_diagnostic.CA1068.severity = warning + +# CA1069: Enums values should not be duplicated dotnet_diagnostic.CA1069.severity = warning + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = warning + +# CA1304: Specify CultureInfo dotnet_diagnostic.CA1304.severity = warning -dotnet_diagnostic.CA1305.severity = warning -dotnet_diagnostic.CA1310.severity = warning -dotnet_diagnostic.CA2101.severity = warning + +# CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = warning + +# CA1308: Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = warning + +# CA1401: P/Invokes should not be visible dotnet_diagnostic.CA1401.severity = warning + +# CA1416: Validate platform compatibility +dotnet_diagnostic.CA1416.severity = warning + +# CA1417: Do not use 'OutAttribute' on string parameters for P/Invokes +dotnet_diagnostic.CA1417.severity = warning + +# CA1419: Provide a parameterless constructor that is as visible as the containing type for concrete types derived from 'System.Runtime.InteropServices.SafeHandle' dotnet_diagnostic.CA1419.severity = warning + +# CA1420: Property, type, or attribute requires runtime marshalling +dotnet_diagnostic.CA1420.severity = warning + +# CA1422: Validate platform compatibility +dotnet_diagnostic.CA1422.severity = warning + +# CA1501: Avoid excessive inheritance +dotnet_diagnostic.CA1501.severity = warning + +# CA1502: Avoid excessive complexity +dotnet_diagnostic.CA1502.severity = warning + +# CA1505: Avoid unmaintainable code +dotnet_diagnostic.CA1505.severity = warning + +# CA1506: Avoid excessive class coupling +dotnet_diagnostic.CA1506.severity = warning + +# CA1508: Avoid dead conditional code +dotnet_diagnostic.CA1508.severity = warning + +# CA1509: Invalid entry in code metrics rule specification file +dotnet_diagnostic.CA1509.severity = warning + +# CA1707: Identifiers should not contain underscores dotnet_diagnostic.CA1707.severity = silent + +# CA1708: Identifiers should differ by more than case dotnet_diagnostic.CA1708.severity = warning + +# CA1710: Identifiers should have correct suffix dotnet_diagnostic.CA1710.severity = warning -dotnet_diagnostic.CA1711.severity = warning + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = silent + +# CA1712: Do not prefix enum values with type name dotnet_diagnostic.CA1712.severity = warning + +# CA1713: Events should not have 'Before' or 'After' prefix +dotnet_diagnostic.CA1713.severity = warning + +# CA1715: Identifiers should have correct prefix dotnet_diagnostic.CA1715.severity = warning -dotnet_diagnostic.CA1716.severity = warning + +# CA1720: Identifier contains type name dotnet_diagnostic.CA1720.severity = warning -dotnet_diagnostic.CA1725.severity = warning -dotnet_diagnostic.CA1806.severity = warning + +# CA1721: Property names should not match get methods +dotnet_diagnostic.CA1721.severity = warning + +# CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = warning + +# CA1727: Use PascalCase for named placeholders +dotnet_diagnostic.CA1727.severity = warning + +# CA1810: Initialize reference type static fields inline +dotnet_diagnostic.CA1810.severity = warning + +# CA1813: Avoid unsealed attributes +dotnet_diagnostic.CA1813.severity = warning + +# CA1815: Override equals and operator equals on value types +dotnet_diagnostic.CA1815.severity = warning + +# CA1816: Dispose methods should call SuppressFinalize +dotnet_diagnostic.CA1816.severity = warning + +# CA1820: Test for empty strings using string length +dotnet_diagnostic.CA1820.severity = warning + +# CA1821: Remove empty Finalizers dotnet_diagnostic.CA1821.severity = warning + +# CA1822: Mark members as static dotnet_diagnostic.CA1822.severity = warning + +# CA1826: Do not use Enumerable methods on indexable collections dotnet_diagnostic.CA1826.severity = warning + +# CA1827: Do not use Count() or LongCount() when Any() can be used dotnet_diagnostic.CA1827.severity = warning + +# CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used dotnet_diagnostic.CA1828.severity = warning -dotnet_diagnostic.CA1829.severity = warning + +# CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder dotnet_diagnostic.CA1830.severity = warning -dotnet_diagnostic.CA1832.severity = warning + +# CA1831: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1831.severity = warning + +# CA1833: Use AsSpan or AsMemory instead of Range-based indexers when appropriate dotnet_diagnostic.CA1833.severity = warning + +# CA1834: Consider using 'StringBuilder.Append(char)' when applicable +dotnet_diagnostic.CA1834.severity = warning + +# CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' dotnet_diagnostic.CA1835.severity = warning -dotnet_diagnostic.CA1836.severity = warning + +# CA1837: Use 'Environment.ProcessId' dotnet_diagnostic.CA1837.severity = warning + +# CA1838: Avoid 'StringBuilder' parameters for P/Invokes dotnet_diagnostic.CA1838.severity = warning + +# CA1839: Use 'Environment.ProcessPath' dotnet_diagnostic.CA1839.severity = warning + +# CA1840: Use 'Environment.CurrentManagedThreadId' dotnet_diagnostic.CA1840.severity = warning + +# CA1842: Do not use 'WhenAll' with a single task dotnet_diagnostic.CA1842.severity = warning -dotnet_diagnostic.CA1843.severity = warning + +# CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' dotnet_diagnostic.CA1844.severity = warning + +# CA1846: Prefer 'AsSpan' over 'Substring' dotnet_diagnostic.CA1846.severity = warning + +# CA1847: Use char literal for a single character lookup dotnet_diagnostic.CA1847.severity = warning -dotnet_diagnostic.CA1848.severity = warning + +# CA1849: Call async methods when in an async method +dotnet_diagnostic.CA1849.severity = warning + +# CA1850: Prefer static 'HashData' method over 'ComputeHash' dotnet_diagnostic.CA1850.severity = warning + +# CA1853: Unnecessary call to 'Dictionary.ContainsKey(key)' +dotnet_diagnostic.CA1853.severity = warning + +# CA2000: Dispose objects before losing scope +dotnet_diagnostic.CA2000.severity = warning + +# CA2002: Do not lock on objects with weak identity +dotnet_diagnostic.CA2002.severity = warning + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = warning + +# CA2008: Do not create tasks without passing a TaskScheduler +dotnet_diagnostic.CA2008.severity = warning + +# CA2009: Do not call ToImmutableCollection on an ImmutableCollection value dotnet_diagnostic.CA2009.severity = warning -dotnet_diagnostic.CA2011.severity = warning + +# CA2012: Use ValueTasks correctly dotnet_diagnostic.CA2012.severity = warning + +# CA1028: Enum Storage should be Int32 +dotnet_diagnostic.CA1028.severity = warning + +# CA1056: URI-like properties should not be strings +dotnet_diagnostic.CA1056.severity = warning + +# CA1067: Override Object.Equals(object) when implementing IEquatable +dotnet_diagnostic.CA1067.severity = warning + +# CA1418: Use valid platform string +dotnet_diagnostic.CA1418.severity = warning + +# CA1421: This method uses runtime marshalling even when the 'DisableRuntimeMarshallingAttribute' is applied +dotnet_diagnostic.CA1421.severity = warning + +# CA1700: Do not name enum values 'Reserved' +dotnet_diagnostic.CA1700.severity = warning + +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = warning + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = warning + +# CA1806: Do not ignore method results +dotnet_diagnostic.CA1806.severity = warning + +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = warning + +# CA1823: Avoid unused private fields +dotnet_diagnostic.CA1823.severity = warning + +# CA1836: Prefer IsEmpty over Count +dotnet_diagnostic.CA1836.severity = warning + +# CA1843: Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1843.severity = warning + +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = warning + +# CA1044: Properties should not be write only +dotnet_diagnostic.CA1044.severity = warning + +# CA1051: Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = warning + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = suggestion + +# CA1064: Exceptions should be public +dotnet_diagnostic.CA1064.severity = warning + +# CA1070: Do not declare event fields as virtual +dotnet_diagnostic.CA1070.severity = warning + +# CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = warning + +# CA1310: Specify StringComparison for correctness +dotnet_diagnostic.CA1310.severity = warning + +# CA1814: Prefer jagged arrays over multidimensional +dotnet_diagnostic.CA1814.severity = warning + +# CA1829: Use Length/Count property instead of Count() when available +dotnet_diagnostic.CA1829.severity = warning + +# CA1832: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1832.severity = warning + +# CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = warning + +# CA1854: Prefer the 'IDictionary.TryGetValue(TKey, out TValue)' method +dotnet_diagnostic.CA1854.severity = warning + +# CA2011: Avoid infinite recursion +dotnet_diagnostic.CA2011.severity = warning + +# CA2013: Do not use ReferenceEquals with value types +dotnet_diagnostic.CA2013.severity = warning + +# CA2015: Do not define finalizers for types derived from MemoryManager +dotnet_diagnostic.CA2015.severity = warning + +# CA2018: 'Buffer.BlockCopy' expects the number of bytes to be copied for the 'count' argument +dotnet_diagnostic.CA2018.severity = warning + +# CA2019: Improper 'ThreadStatic' field initialization +dotnet_diagnostic.CA2019.severity = warning + +# CA2100: Review SQL queries for security vulnerabilities +dotnet_diagnostic.CA2100.severity = warning + +# CA2109: Review visible event handlers +dotnet_diagnostic.CA2109.severity = warning + +# CA2119: Seal methods that satisfy private interfaces +dotnet_diagnostic.CA2119.severity = warning + +# CA2153: Do Not Catch Corrupted State Exceptions +dotnet_diagnostic.CA2153.severity = warning + +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = warning + +# CA2101: Specify marshaling for P/Invoke string arguments +dotnet_diagnostic.CA2101.severity = warning + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = warning + +# CA2207: Initialize value type static fields inline +dotnet_diagnostic.CA2207.severity = warning + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = warning + +# CA2213: Disposable fields should be disposed +dotnet_diagnostic.CA2213.severity = warning + +# CA2214: Do not call overridable methods in constructors +dotnet_diagnostic.CA2214.severity = warning + +# CA2215: Dispose methods should call base class dispose +dotnet_diagnostic.CA2215.severity = warning + +# CA2216: Disposable types should declare finalizer +dotnet_diagnostic.CA2216.severity = warning + +# CA2217: Do not mark enums with FlagsAttribute +dotnet_diagnostic.CA2217.severity = warning + +# CA2219: Do not raise exceptions in finally clauses +dotnet_diagnostic.CA2219.severity = warning + +# CA2225: Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = warning + +# CA2226: Operators should have symmetrical overloads +dotnet_diagnostic.CA2226.severity = warning + +# CA2227: Collection properties should be read only +dotnet_diagnostic.CA2227.severity = warning + +# CA2229: Implement serialization constructors +dotnet_diagnostic.CA2229.severity = warning + +# CA2231: Overload operator equals on overriding value type Equals +dotnet_diagnostic.CA2231.severity = warning + +# CA2235: Mark all non-serializable fields +dotnet_diagnostic.CA2235.severity = warning + +# CA2241: Provide correct arguments to formatting methods +dotnet_diagnostic.CA2241.severity = warning + +# CA2242: Test for NaN correctly +dotnet_diagnostic.CA2242.severity = warning + +# CA2243: Attribute string literals should parse correctly +dotnet_diagnostic.CA2243.severity = warning + +# CA2211: Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = warning + +# CA2237: Mark ISerializable types with serializable +dotnet_diagnostic.CA2237.severity = none + +# CA2244: Do not duplicate indexed element initializations +dotnet_diagnostic.CA2244.severity = warning + +# CA2245: Do not assign a property to itself +dotnet_diagnostic.CA2245.severity = warning + +# CA2246: Assigning symbol and its member in the same statement +dotnet_diagnostic.CA2246.severity = warning + +# CA2247: Argument passed to TaskCompletionSource constructor should be TaskCreationOptions enum instead of TaskContinuationOptions enum +dotnet_diagnostic.CA2247.severity = warning + +# CA2248: Provide correct 'enum' argument to 'Enum.HasFlag' +dotnet_diagnostic.CA2248.severity = warning + +# CA2249: Consider using 'string.Contains' instead of 'string.IndexOf' +dotnet_diagnostic.CA2249.severity = warning + +# CA2250: Use 'ThrowIfCancellationRequested' +dotnet_diagnostic.CA2250.severity = warning + +# CA2251: Use 'string.Equals' +dotnet_diagnostic.CA2251.severity = warning + +# CA2253: Named placeholders should not be numeric values +dotnet_diagnostic.CA2253.severity = warning + +# CA2254: Template should be a static expression +dotnet_diagnostic.CA2254.severity = warning + +# CA2300: Do not use insecure deserializer BinaryFormatter +dotnet_diagnostic.CA2300.severity = warning + +# CA2301: Do not call BinaryFormatter.Deserialize without first setting BinaryFormatter.Binder +dotnet_diagnostic.CA2301.severity = warning + +# CA2302: Ensure BinaryFormatter.Binder is set before calling BinaryFormatter.Deserialize +dotnet_diagnostic.CA2302.severity = warning + +# CA2305: Do not use insecure deserializer LosFormatter +dotnet_diagnostic.CA2305.severity = warning + +# CA2311: Do not deserialize without first setting NetDataContractSerializer.Binder +dotnet_diagnostic.CA2311.severity = warning + +# CA2312: Ensure NetDataContractSerializer.Binder is set before deserializing +dotnet_diagnostic.CA2312.severity = warning + +# CA2315: Do not use insecure deserializer ObjectStateFormatter +dotnet_diagnostic.CA2315.severity = warning + +# CA2321: Do not deserialize with JavaScriptSerializer using a SimpleTypeResolver +dotnet_diagnostic.CA2321.severity = warning + +# CA2326: Do not use TypeNameHandling values other than None +dotnet_diagnostic.CA2326.severity = warning + +# CA2327: Do not use insecure JsonSerializerSettings +dotnet_diagnostic.CA2327.severity = warning + +# CA2328: Ensure that JsonSerializerSettings are secure +dotnet_diagnostic.CA2328.severity = warning + +# CA2329: Do not deserialize with JsonSerializer using an insecure configuration +dotnet_diagnostic.CA2329.severity = warning + +# CA2330: Ensure that JsonSerializer has a secure configuration when deserializing +dotnet_diagnostic.CA2330.severity = warning + +# CA2351: Do not use DataSet.ReadXml() with untrusted data +dotnet_diagnostic.CA2351.severity = warning + +# CA2361: Ensure auto-generated class containing DataSet.ReadXml() is not used with untrusted data +dotnet_diagnostic.CA2361.severity = warning + +# CA3001: Review code for SQL injection vulnerabilities +dotnet_diagnostic.CA3001.severity = warning + +# CA3002: Review code for XSS vulnerabilities +dotnet_diagnostic.CA3002.severity = warning + +# CA3003: Review code for file path injection vulnerabilities +dotnet_diagnostic.CA3003.severity = warning + +# CA3005: Review code for LDAP injection vulnerabilities +dotnet_diagnostic.CA3005.severity = warning + +# CA3006: Review code for process command injection vulnerabilities +dotnet_diagnostic.CA3006.severity = warning + +# CA3007: Review code for open redirect vulnerabilities +dotnet_diagnostic.CA3007.severity = warning + +# CA3008: Review code for XPath injection vulnerabilities +dotnet_diagnostic.CA3008.severity = warning + +# CA3009: Review code for XML injection vulnerabilities +dotnet_diagnostic.CA3009.severity = warning + +# CA2310: Do not use insecure deserializer NetDataContractSerializer +dotnet_diagnostic.CA2310.severity = warning + +# CA2322: Ensure JavaScriptSerializer is not initialized with SimpleTypeResolver before deserializing +dotnet_diagnostic.CA2322.severity = warning + +# CA2350: Do not use DataTable.ReadXml() with untrusted data +dotnet_diagnostic.CA2350.severity = warning + +# CA3004: Review code for information disclosure vulnerabilities +dotnet_diagnostic.CA3004.severity = warning + +# CA3010: Review code for XAML injection vulnerabilities +dotnet_diagnostic.CA3010.severity = warning + +# CA3011: Review code for DLL injection vulnerabilities +dotnet_diagnostic.CA3011.severity = warning + +# CA3012: Review code for regex injection vulnerabilities +dotnet_diagnostic.CA3012.severity = warning + +# CA3061: Do Not Add Schema By URL dotnet_diagnostic.CA3061.severity = warning -dotnet_diagnostic.CA3075.severity = warning + +# CA3076: Insecure XSLT script processing dotnet_diagnostic.CA3076.severity = warning + +# CA3077: Insecure Processing in API Design, XmlDocument and XmlTextReader dotnet_diagnostic.CA3077.severity = warning + +# CA3147: Mark Verb Handlers With Validate Antiforgery Token dotnet_diagnostic.CA3147.severity = warning + +# CA5350: Do Not Use Weak Cryptographic Algorithms dotnet_diagnostic.CA5350.severity = warning + +# CA5351: Do Not Use Broken Cryptographic Algorithms dotnet_diagnostic.CA5351.severity = warning + +# CA5358: Review cipher mode usage with cryptography experts +dotnet_diagnostic.CA5358.severity = warning + +# CA5359: Do Not Disable Certificate Validation dotnet_diagnostic.CA5359.severity = warning + +# CA5360: Do Not Call Dangerous Methods In Deserialization dotnet_diagnostic.CA5360.severity = warning + +# CA5362: Potential reference cycle in deserialized object graph +dotnet_diagnostic.CA5362.severity = warning + +# CA5363: Do Not Disable Request Validation dotnet_diagnostic.CA5363.severity = warning + +# CA5364: Do Not Use Deprecated Security Protocols dotnet_diagnostic.CA5364.severity = warning + +# CA5365: Do Not Disable HTTP Header Checking dotnet_diagnostic.CA5365.severity = warning -dotnet_diagnostic.CA5366.severity = warning + +# CA5367: Do Not Serialize Types With Pointer Fields +dotnet_diagnostic.CA5367.severity = warning + +# CA5368: Set ViewStateUserKey For Classes Derived From Page dotnet_diagnostic.CA5368.severity = warning + +# CA3075: Insecure DTD processing in XML +dotnet_diagnostic.CA3075.severity = warning + +# CA5361: Do Not Disable SChannel Use of Strong Crypto +dotnet_diagnostic.CA5361.severity = warning + +# CA5366: Use XmlReader for 'DataSet.ReadXml()' +dotnet_diagnostic.CA5366.severity = warning + +# CA5369: Use XmlReader for 'XmlSerializer.Deserialize()' dotnet_diagnostic.CA5369.severity = warning + +# CA5370: Use XmlReader for XmlValidatingReader constructor dotnet_diagnostic.CA5370.severity = warning + +# CA5371: Use XmlReader for 'XmlSchema.Read()' dotnet_diagnostic.CA5371.severity = warning + +# CA5372: Use XmlReader for XPathDocument constructor dotnet_diagnostic.CA5372.severity = warning -dotnet_diagnostic.CA5373.severity = warning + +# CA5374: Do Not Use XslTransform dotnet_diagnostic.CA5374.severity = warning -dotnet_diagnostic.CA5379.severity = warning + +# CA5375: Do Not Use Account Shared Access Signature +dotnet_diagnostic.CA5375.severity = warning + +# CA5376: Use SharedAccessProtocol HttpsOnly +dotnet_diagnostic.CA5376.severity = warning + +# CA5377: Use Container Level Access Policy +dotnet_diagnostic.CA5377.severity = warning + +# CA5378: Do not disable ServicePointManagerSecurityProtocols +dotnet_diagnostic.CA5378.severity = warning + +# CA5380: Do Not Add Certificates To Root Store +dotnet_diagnostic.CA5380.severity = warning + +# CA5381: Ensure Certificates Are Not Added To Root Store +dotnet_diagnostic.CA5381.severity = warning + +# CA5382: Use Secure Cookies In ASP.NET Core +dotnet_diagnostic.CA5382.severity = warning + +# CA5383: Ensure Use Secure Cookies In ASP.NET Core +dotnet_diagnostic.CA5383.severity = warning + +# CA5384: Do Not Use Digital Signature Algorithm (DSA) dotnet_diagnostic.CA5384.severity = warning + +# CA5385: Use Rivest-Shamir-Adleman (RSA) Algorithm With Sufficient Key Size dotnet_diagnostic.CA5385.severity = warning + +# CA5386: Avoid hardcoding SecurityProtocolType value +dotnet_diagnostic.CA5386.severity = warning + +# CA5387: Do Not Use Weak Key Derivation Function With Insufficient Iteration Count +dotnet_diagnostic.CA5387.severity = warning + +# CA5388: Ensure Sufficient Iteration Count When Using Weak Key Derivation Function +dotnet_diagnostic.CA5388.severity = warning + +# CA5389: Do Not Add Archive Item's Path To The Target File System Path +dotnet_diagnostic.CA5389.severity = warning + +# CA5379: Ensure Key Derivation Function algorithm is sufficiently strong +dotnet_diagnostic.CA5379.severity = warning + +# CA5373: Do not use obsolete key derivation function +dotnet_diagnostic.CA5373.severity = warning + +# CA5390: Do not hard-code encryption key +dotnet_diagnostic.CA5390.severity = warning + +# CA5391: Use antiforgery tokens in ASP.NET Core MVC controllers +dotnet_diagnostic.CA5391.severity = warning + +# CA5392: Use DefaultDllImportSearchPaths attribute for P/Invokes +dotnet_diagnostic.CA5392.severity = warning + +# CA5393: Do not use unsafe DllImportSearchPath value +dotnet_diagnostic.CA5393.severity = warning + +# CA5394: Do not use insecure randomness +dotnet_diagnostic.CA5394.severity = warning + +# CA5395: Miss HttpVerb attribute for action methods +dotnet_diagnostic.CA5395.severity = warning + +# CA5396: Set HttpOnly to true for HttpCookie +dotnet_diagnostic.CA5396.severity = warning + +# CA5397: Do not use deprecated SslProtocols values dotnet_diagnostic.CA5397.severity = warning -dotnet_diagnostic.IDE0033.severity = warning -dotnet_diagnostic.IDE0044.severity = suggestion -dotnet_diagnostic.IDE0070.severity = warning -dotnet_diagnostic.CA1816.severity = warning -dotnet_diagnostic.CA2201.severity = warning -dotnet_diagnostic.CA2208.severity = warning -dotnet_diagnostic.CA2211.severity = silent -dotnet_diagnostic.CA2215.severity = warning -dotnet_diagnostic.CA2219.severity = warning -dotnet_diagnostic.CA2229.severity = warning -dotnet_diagnostic.CA2231.severity = warning -dotnet_diagnostic.CA2241.severity = warning -dotnet_diagnostic.CA2242.severity = warning -dotnet_diagnostic.CA2245.severity = warning -dotnet_diagnostic.CA2248.severity = warning -dotnet_diagnostic.CA2250.severity = warning -dotnet_diagnostic.CA2251.severity = warning -dotnet_diagnostic.CA2253.severity = warning -dotnet_diagnostic.CA2254.severity = warning -dotnet_style_readonly_field = true:suggestion -dotnet_style_predefined_type_for_locals_parameters_members = true:warning -dotnet_style_predefined_type_for_member_access = true:warning -dotnet_style_require_accessibility_modifiers = always:suggestion -dotnet_style_allow_multiple_blank_lines_experimental = false:silent -dotnet_style_allow_statement_immediately_after_block_experimental = false:suggestion -dotnet_code_quality_unused_parameters = all:warning -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_property = false:warning -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:warning -dotnet_diagnostic.CA1036.severity = warning -dotnet_diagnostic.CA1727.severity = warning -[*.vb] -dotnet_diagnostic.CA1047.severity = warning \ No newline at end of file + +# CA5399: HttpClients should enable certificate revocation list checks +dotnet_diagnostic.CA5399.severity = warning + +# CA5400: Ensure HttpClient certificate revocation list check is not disabled +dotnet_diagnostic.CA5400.severity = warning + +# CA5401: Do not use CreateEncryptor with non-default IV +dotnet_diagnostic.CA5401.severity = warning + +# CA5402: Use CreateEncryptor with the default IV +dotnet_diagnostic.CA5402.severity = warning + +# CA5403: Do not hard-code certificate +dotnet_diagnostic.CA5403.severity = warning + +# CA5404: Do not disable token validation checks +dotnet_diagnostic.CA5404.severity = warning + +# CA5405: Do not always skip token validation in delegates +dotnet_diagnostic.CA5405.severity = warning + +# CA5398: Avoid hardcoded SslProtocols values +dotnet_diagnostic.CA5398.severity = warning + +# SYSLIB1054: Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time +dotnet_diagnostic.SYSLIB1054.severity = warning + +# SYSLIB1045: Convert to 'GeneratedRegexAttribute'. +dotnet_diagnostic.SYSLIB1045.severity = warning + +# IDE0160: Convert to block scoped namespace +csharp_style_namespace_declarations = file_scoped + +# IDE0008: Use explicit type +csharp_style_var_when_type_is_apparent = true + +# IDE0011: Add braces +csharp_prefer_braces = when_multiline + +# SA1305: Field names should not use Hungarian notation +dotnet_diagnostic.SA1305.severity = warning + +# SA1412: Store files as UTF-8 with byte order mark +dotnet_diagnostic.SA1412.severity = warning + +# SA1609: Property documentation should have value +dotnet_diagnostic.SA1609.severity = suggestion + +# SA1639: File header should have summary +dotnet_diagnostic.SA1639.severity = warning + +# SX1101: Do not prefix local calls with 'this.' +dotnet_diagnostic.SX1101.severity = warning + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none + +# IDE0065: Misplaced using directive +csharp_using_directive_placement = inside_namespace + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# SA1503: Braces should not be omitted +dotnet_diagnostic.SA1503.severity = none + +# SA1413: Use trailing comma in multi-line initializers +dotnet_diagnostic.SA1413.severity = none + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = suggestion + +# SA1642: Constructor summary documentation should begin with standard text +dotnet_diagnostic.SA1642.severity = suggestion + +# SA1615: Element return value should be documented +dotnet_diagnostic.SA1615.severity = suggestion + +# SA1611: Element parameters should be documented +dotnet_diagnostic.SA1611.severity = suggestion + +# SA1623: Property summary documentation should match accessors +dotnet_diagnostic.SA1623.severity = suggestion + +# SA1311: Static readonly fields should begin with upper-case letter +dotnet_diagnostic.SA1311.severity = none + +# SA1310: Field names should not contain underscore +dotnet_diagnostic.SA1310.severity = suggestion + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = suggestion diff --git a/.gitattributes b/.gitattributes index 7ca0b3c27..fc4ab00e3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ ############################################################################### * text=auto *.ps1 eol=lf +*.sh eol=lf ############################################################################### # Set default behavior for command prompt diff. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d68b4682..996eaf8d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,12 +14,12 @@ jobs: Game: [Ares,TS,YR] steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.6.0 with: fetch-depth: 0 - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v3.0.3 + uses: actions/setup-dotnet@v3.2.0 with: dotnet-version: '7.x.x' @@ -27,7 +27,7 @@ jobs: run: ./BuildScripts/Build-${{matrix.Game}}.ps1 shell: pwsh - - uses: actions/upload-artifact@v3.1.1 + - uses: actions/upload-artifact@v3.1.2 name: Upload Artifacts with: name: artifacts-${{matrix.Game}} diff --git a/.github/workflows/pr-build-comment.yml b/.github/workflows/pr-build-comment.yml index eabd96a0c..78d1eda30 100644 --- a/.github/workflows/pr-build-comment.yml +++ b/.github/workflows/pr-build-comment.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-22.04 steps: - - uses: actions/github-script@v6.3.1 + - uses: actions/github-script@v6.4.1 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml diff --git a/BuildScripts/Common.ps1 b/BuildScripts/Common.ps1 index 1396219bd..d8f70aae2 100644 --- a/BuildScripts/Common.ps1 +++ b/BuildScripts/Common.ps1 @@ -14,10 +14,10 @@ $EngineMap = @{ function Build-Project($Configuration, $Game, $Engine, $Framework) { $Output = Join-Path $CompiledRoot $Game $Output Resources Binaries ($EngineMap[$Engine]) if ($Engine -EQ 'WindowsXNA') { - dotnet publish $ProjectPath --configuration=$Configuration -property:GAME=$Game -property:ENGINE=$Engine --framework=$Framework --output=$Output --arch=x86 + dotnet publish $ProjectPath -c $Configuration -p:GAME=$Game -p:ENGINE=$Engine -f $Framework -o $Output -p:SatelliteResourceLanguages=en -a x86 } else { - dotnet publish $ProjectPath --configuration=$Configuration -property:GAME=$Game -property:ENGINE=$Engine --framework=$Framework --output=$Output + dotnet publish $ProjectPath -c $Configuration -p:GAME=$Game -p:ENGINE=$Engine -f $Framework -o $Output -p:SatelliteResourceLanguages=en } if ($LASTEXITCODE) { throw "Build failed for $Game $Engine $Framework $Configuration" diff --git a/ClientCore/ClientConfiguration.cs b/ClientCore/ClientConfiguration.cs index 5f172d05e..cd758184f 100644 --- a/ClientCore/ClientConfiguration.cs +++ b/ClientCore/ClientConfiguration.cs @@ -15,7 +15,6 @@ public class ClientConfiguration private const string GENERAL = "General"; private const string AUDIO = "Audio"; private const string SETTINGS = "Settings"; - private const string LINKS = "Links"; private const string TRANSLATIONS = "Translations"; private const string CLIENT_SETTINGS = "DTACnCNetClient.ini"; @@ -53,14 +52,7 @@ protected ClientConfiguration() /// The object of the ClientConfiguration class. public static ClientConfiguration Instance { - get - { - if (_instance == null) - { - _instance = new ClientConfiguration(); - } - return _instance; - } + get { return _instance ??= new ClientConfiguration(); } } public void RefreshSettings() @@ -209,13 +201,13 @@ public void RefreshSettings() public string LongGameName => clientDefinitionsIni.GetStringValue(SETTINGS, "LongGameName", "Tiberian Sun"); - public string LongSupportURL => clientDefinitionsIni.GetStringValue(SETTINGS, "LongSupportURL", "http://www.moddb.com/members/rampastring"); + public string LongSupportURL => clientDefinitionsIni.GetStringValue(SETTINGS, "LongSupportURL", $"{Uri.UriSchemeHttps}://www.moddb.com/members/rampastring"); public string ShortSupportURL => clientDefinitionsIni.GetStringValue(SETTINGS, "ShortSupportURL", "www.moddb.com/members/rampastring"); - public string ChangelogURL => clientDefinitionsIni.GetStringValue(SETTINGS, "ChangelogURL", "http://www.moddb.com/mods/the-dawn-of-the-tiberium-age/tutorials/change-log"); + public string ChangelogURL => clientDefinitionsIni.GetStringValue(SETTINGS, "ChangelogURL", $"{Uri.UriSchemeHttps}://www.moddb.com/mods/the-dawn-of-the-tiberium-age/tutorials/change-log"); - public string CreditsURL => clientDefinitionsIni.GetStringValue(SETTINGS, "CreditsURL", "http://www.moddb.com/mods/the-dawn-of-the-tiberium-age/tutorials/credits#Rampastring"); + public string CreditsURL => clientDefinitionsIni.GetStringValue(SETTINGS, "CreditsURL", $"{Uri.UriSchemeHttps}://www.moddb.com/mods/the-dawn-of-the-tiberium-age/tutorials/credits#Rampastring"); public string ManualDownloadURL => clientDefinitionsIni.GetStringValue(SETTINGS, "ManualDownloadURL", string.Empty); @@ -400,7 +392,8 @@ public OSVersion GetOperatingSystemVersion() /// public class ClientConfigurationException : Exception { - public ClientConfigurationException(string message) : base(message) + public ClientConfigurationException(string message, Exception ex = null) + : base(message, ex) { } } diff --git a/ClientCore/ClientCore.csproj b/ClientCore/ClientCore.csproj index 96160385b..ff8dfbdc6 100644 --- a/ClientCore/ClientCore.csproj +++ b/ClientCore/ClientCore.csproj @@ -4,11 +4,8 @@ CnCNet Client Core Library CnCNet CnCNet Client - Copyright © CnCNet, Rampastring 2011-2022 + Copyright © CnCNet, Rampastring 2011-2023 CnCNet - 2.0.0.3 - 2.0.0.3 - 2.0.0.3 ClientCore ClientCore @@ -46,14 +43,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + diff --git a/ClientCore/CnCNet5/NameValidator.cs b/ClientCore/CnCNet5/NameValidator.cs index c0dfa731f..a71268fc2 100644 --- a/ClientCore/CnCNet5/NameValidator.cs +++ b/ClientCore/CnCNet5/NameValidator.cs @@ -21,7 +21,7 @@ public static string IsNameValid(string name) if (profanityFilter.IsOffensive(name)) return "Please enter a name that is less offensive.".L10N("Client:ClientCore:NameOffensive"); - if (int.TryParse(name.Substring(0, 1), out _)) + if (int.TryParse(name[..1], out _)) return "The first character in the player name cannot be a number.".L10N("Client:ClientCore:NameFirstIsNumber"); if (name[0] == '-') @@ -59,7 +59,7 @@ public static string GetValidOfflineName(string name) string validName = new string(name.Trim().Where(c => !disallowedCharacters.Contains(c)).ToArray()); if (validName.Length > ClientConfiguration.Instance.MaxNameLength) - return validName.Substring(0, ClientConfiguration.Instance.MaxNameLength); + return validName[..ClientConfiguration.Instance.MaxNameLength]; return validName; } diff --git a/ClientCore/Extensions/EnumExtensions.cs b/ClientCore/Extensions/EnumExtensions.cs index 6dd2f4afc..d5b961d36 100644 --- a/ClientCore/Extensions/EnumExtensions.cs +++ b/ClientCore/Extensions/EnumExtensions.cs @@ -4,21 +4,18 @@ namespace ClientCore.Extensions { public static class EnumExtensions { - public static T Next(this T src) where T : Enum + public static T Next(this T src) + where T : Enum { - T[] Arr = GetValues(src); - int nextIndex = Array.IndexOf(Arr, src) + 1; - return Arr.Length == nextIndex ? Arr[0] : Arr[nextIndex]; + T[] values = GetValues(src); + int nextIndex = Array.IndexOf(values, src) + 1; + return values.Length == nextIndex ? values[0] : values[nextIndex]; } - public static T First(this T src) where T : Enum - { - return GetValues(src)[0]; - } - - private static T[] GetValues(T src) where T : Enum + private static T[] GetValues(T src) + where T : Enum { return (T[])Enum.GetValues(src.GetType()); } } -} +} \ No newline at end of file diff --git a/ClientCore/Extensions/StringExtensions.cs b/ClientCore/Extensions/StringExtensions.cs index 11a9d2368..c340a89f0 100644 --- a/ClientCore/Extensions/StringExtensions.cs +++ b/ClientCore/Extensions/StringExtensions.cs @@ -10,16 +10,16 @@ public static string GetLink(this string text) if (string.IsNullOrWhiteSpace(text)) return null; - int index = text.IndexOf("http://", StringComparison.Ordinal); + int index = text.IndexOf($"{Uri.UriSchemeHttp}://", StringComparison.Ordinal); if (index == -1) - index = text.IndexOf("ftp://", StringComparison.Ordinal); + index = text.IndexOf($"{Uri.UriSchemeFtp}://", StringComparison.Ordinal); if (index == -1) - index = text.IndexOf("https://", StringComparison.Ordinal); + index = text.IndexOf($"{Uri.UriSchemeHttps}://", StringComparison.Ordinal); if (index == -1) return null; // No link found - string link = text.Substring(index); + string link = text[index..]; return link.Split(' ')[0]; // Nuke any words coming after the link } diff --git a/ClientCore/Extensions/TaskExtensions.cs b/ClientCore/Extensions/TaskExtensions.cs new file mode 100644 index 000000000..192941f78 --- /dev/null +++ b/ClientCore/Extensions/TaskExtensions.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ClientCore.Extensions; + +public static class TaskExtensions +{ + /// + /// Runs a and guarantees all exceptions are caught and handled even when the is not directly awaited. + /// + /// The who's exceptions will be handled. + /// true to attempt to marshal the continuation back to the original context captured; otherwise, false. + /// Returns a that awaited and handled the original . + public static async Task HandleTask(this Task task, bool continueOnCapturedContext = false) + { + try + { + await task.ConfigureAwait(continueOnCapturedContext); + } + catch (Exception ex) + { + ProgramConstants.HandleException(ex); + } + } + + /// + /// Runs a and guarantees all exceptions are caught and handled even when the is not directly awaited. + /// + /// The type of 's return value. + /// The who's exceptions will be handled. + /// true to attempt to marshal the continuation back to the original context captured; otherwise, false. + /// Returns a that awaited and handled the original . + public static async Task HandleTask(this Task task, bool continueOnCapturedContext = false) + { + try + { + return await task.ConfigureAwait(continueOnCapturedContext); + } + catch (Exception ex) + { + ProgramConstants.HandleException(ex); + } + + return default; + } + + /// + /// Executes a list of tasks and waits for all of them to complete and throws an containing all exceptions from all tasks. + /// When using only the first thrown exception from a single may be observed. + /// + /// The type of 's return value. + /// The list of s who's exceptions will be handled. + /// true to attempt to marshal the continuation back to the original context captured; otherwise, false. + /// Returns a that awaited and handled the original . + public static async Task WhenAllSafe(IEnumerable> tasks, bool continueOnCapturedContext = false) + { + var whenAllTask = Task.WhenAll(tasks); + + try + { + return await whenAllTask.ConfigureAwait(continueOnCapturedContext); + } + catch + { + if (whenAllTask.Exception is null) + throw; + + throw whenAllTask.Exception; + } + } + + /// + /// Executes a list of tasks and waits for all of them to complete and throws an containing all exceptions from all tasks. + /// When using only the first thrown exception from a single may be observed. + /// + /// The list of s who's exceptions will be handled. + /// true to attempt to marshal the continuation back to the original context captured; otherwise, false. + /// Returns a that awaited and handled the original . + public static async Task WhenAllSafe(IEnumerable tasks, bool continueOnCapturedContext = false) + { + var whenAllTask = Task.WhenAll(tasks); + + try + { + await whenAllTask.ConfigureAwait(continueOnCapturedContext); + } + catch + { + if (whenAllTask.Exception is null) + throw; + + throw whenAllTask.Exception; + } + } + + /// + /// Runs a and guarantees all exceptions are caught and handled even when the is not directly awaited. + /// + /// The who's exceptions will be handled. + /// true to attempt to marshal the continuation back to the original context captured; otherwise, false. + public static async void HandleTask(this ValueTask task, bool continueOnCapturedContext = false) + { + try + { + await task.ConfigureAwait(continueOnCapturedContext); + } + catch (Exception ex) + { + ProgramConstants.HandleException(ex); + } + } +} \ No newline at end of file diff --git a/ClientCore/INIProcessing/PreprocessorBackgroundTask.cs b/ClientCore/INIProcessing/PreprocessorBackgroundTask.cs index 2d129d73d..cbed66aa3 100644 --- a/ClientCore/INIProcessing/PreprocessorBackgroundTask.cs +++ b/ClientCore/INIProcessing/PreprocessorBackgroundTask.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading.Tasks; using System.Collections.Generic; +using ClientCore.Extensions; namespace ClientCore.INIProcessing { @@ -33,7 +34,7 @@ public static PreprocessorBackgroundTask Instance public void Run() { - task = Task.Factory.StartNew(CheckFiles); + task = Task.Run(CheckFiles).HandleTask(); } private static void CheckFiles() diff --git a/ClientCore/ProfanityFilter.cs b/ClientCore/ProfanityFilter.cs index 87970f0dd..e6e2be844 100644 --- a/ClientCore/ProfanityFilter.cs +++ b/ClientCore/ProfanityFilter.cs @@ -80,7 +80,7 @@ private string ToRegexPattern(string wildcardSearch) regexPattern = regexPattern.Replace(@"\?", "."); if (regexPattern.StartsWith(".*?")) { - regexPattern = regexPattern.Substring(3); + regexPattern = regexPattern[3..]; regexPattern = @"(^\b)*?" + regexPattern; } regexPattern = @"\b" + regexPattern + @"\b"; diff --git a/ClientCore/ProgramConstants.cs b/ClientCore/ProgramConstants.cs index feb207172..e1f7510da 100644 --- a/ClientCore/ProgramConstants.cs +++ b/ClientCore/ProgramConstants.cs @@ -27,14 +27,19 @@ public static class ProgramConstants #endif public static string ClientUserFilesPath => SafePath.CombineDirectoryPath(GamePath, "Client"); + public static string CREDITS_URL = string.Empty; + public static bool USE_ISOMETRIC_CELLS = true; + public static int TDRA_WAYPOINT_COEFFICIENT = 128; + public static int MAP_CELL_SIZE_X = 48; + public static int MAP_CELL_SIZE_Y = 24; + public static OSVersion OSId = OSVersion.UNKNOWN; public static event EventHandler PlayerNameChanged; public const string QRES_EXECUTABLE = "qres.dat"; - public const string CNCNET_PROTOCOL_REVISION = "R10"; + public const string CNCNET_PROTOCOL_REVISION = "R11"; public const string LAN_PROTOCOL_REVISION = "RL7"; - public const int LAN_PORT = 1234; public const int LAN_INGAME_PORT = 1234; public const int LAN_LOBBY_PORT = 1232; public const int LAN_GAME_LOBBY_PORT = 1233; @@ -43,7 +48,8 @@ public static class ProgramConstants public const string SPAWNMAP_INI = "spawnmap.ini"; public const string SPAWNER_SETTINGS = "spawn.ini"; - public const string SAVED_GAME_SPAWN_INI = "Saved Games/spawnSG.ini"; + public const string SAVED_GAME_SPAWN_INI = SAVED_GAMES_DIRECTORY + "/spawnSG.ini"; + public const string SAVED_GAMES_DIRECTORY = "Saved Games"; /// /// The locale code that corresponds to the language the hardcoded client strings are in. @@ -58,11 +64,17 @@ public static class ProgramConstants /// public const string INI_NEWLINE_PATTERN = "@"; + public const string REPLAYS_DIRECTORY = "Replays"; + public const string CNCNET_TUNNEL_LIST_URL = "https://cncnet.org/api/v1/master-list"; + public const string CNCNET_DYNAMIC_TUNNELS = "DYNAMIC"; public const int GAME_ID_MAX_LENGTH = 4; public static readonly Encoding LAN_ENCODING = Encoding.UTF8; public static string GAME_VERSION = "Undefined"; + public static string GAME_NAME_LONG = "CnCNet Client"; + public static string GAME_NAME_SHORT = "CnCNet"; + public static string SUPPORT_URL_SHORT = "www.cncnet.org"; private static string PlayerName = "No name"; public static string PLAYERNAME @@ -94,9 +106,6 @@ public static string GetBaseResourcePath() return SafePath.CombineDirectoryPath(GamePath, BASE_RESOURCE_PATH); } - public const string GAME_INVITE_CTCP_COMMAND = "INVITE"; - public const string GAME_INVITATION_FAILED_CTCP_COMMAND = "INVITATION_FAILED"; - public static string GetAILevelName(int aiLevel) { if (aiLevel > -1 && aiLevel < AI_PLAYER_NAMES.Count) @@ -127,5 +136,79 @@ public static string GetAILevelName(int aiLevel) if (exit) Environment.Exit(1); }; + + /// + /// Logs all details of an exception to the logfile without further action. + /// + /// The to log. + /// /// Optional message to accompany the error. + public static void LogException(Exception ex, string message = null) + { + LogExceptionRecursive(ex, message); + } + + private static void LogExceptionRecursive(Exception ex, string message = null, bool innerException = false) + { + if (!innerException) + Logger.Log(message); + else + Logger.Log("InnerException info:"); + + Logger.Log("Type: " + ex.GetType()); + Logger.Log("Message: " + ex.Message); + Logger.Log("Source: " + ex.Source); + Logger.Log("TargetSite.Name: " + ex.TargetSite?.Name); + Logger.Log("Stacktrace: " + ex.StackTrace); + + if (ex is AggregateException aggregateException) + { + foreach (Exception aggregateExceptionInnerException in aggregateException.InnerExceptions) + { + LogExceptionRecursive(aggregateExceptionInnerException, null, true); + } + } + else if (ex.InnerException is not null) + { + LogExceptionRecursive(ex.InnerException, null, true); + } + } + + /// + /// Logs all details of an exception to the logfile, notifies the user, and exits the application. + /// + /// The to log. + public static void HandleException(Exception ex) + { + LogExceptionRecursive(ex, "KABOOOOOOM!!! Info:"); + + string errorLogPath = SafePath.CombineFilePath(ClientUserFilesPath, "ClientCrashLogs", FormattableString.Invariant($"ClientCrashLog{DateTime.Now.ToString("_yyyy_MM_dd_HH_mm")}.txt")); + bool crashLogCopied = false; + + try + { + DirectoryInfo crashLogsDirectoryInfo = SafePath.GetDirectory(ClientUserFilesPath, "ClientCrashLogs"); + + if (!crashLogsDirectoryInfo.Exists) + crashLogsDirectoryInfo.Create(); + + File.Copy(SafePath.CombineFilePath(ClientUserFilesPath, "client.log"), errorLogPath, true); + crashLogCopied = true; + } + catch + { + } + + string error = string.Format("{0} has crashed. Error message:".L10N("Client:Main:FatalErrorText1") + Environment.NewLine + Environment.NewLine + + ex.Message + Environment.NewLine + Environment.NewLine + (crashLogCopied ? + "A crash log has been saved to the following file:".L10N("Client:Main:FatalErrorText2") + " " + Environment.NewLine + Environment.NewLine + + errorLogPath + Environment.NewLine + Environment.NewLine : string.Empty) + + (crashLogCopied ? "If the issue is repeatable, contact the {1} staff at {2} and provide the crash log file.".L10N("Client:Main:FatalErrorText3") : + "If the issue is repeatable, contact the {1} staff at {2}.".L10N("Client:Main:FatalErrorText4")), + GAME_NAME_LONG, + GAME_NAME_SHORT, + SUPPORT_URL_SHORT); + + DisplayErrorAction("KABOOOOOOOM".L10N("Client:Main:FatalErrorTitle"), error, true); + } } } \ No newline at end of file diff --git a/ClientCore/SavedGameManager.cs b/ClientCore/SavedGameManager.cs index f32d63533..d5bc99d84 100644 --- a/ClientCore/SavedGameManager.cs +++ b/ClientCore/SavedGameManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using Rampastring.Tools; namespace ClientCore @@ -10,9 +11,7 @@ namespace ClientCore /// public static class SavedGameManager { - private const string SAVED_GAMES_DIRECTORY = "Saved Games"; - - private static bool saveRenameInProgress = false; + private static bool saveRenameInProgress; public static int GetSaveGameCount() { @@ -62,7 +61,7 @@ public static bool AreSavedGamesAvailable() private static string GetSaveGameDirectoryPath() { - return SafePath.CombineDirectoryPath(ProgramConstants.GamePath, SAVED_GAMES_DIRECTORY); + return SafePath.CombineDirectoryPath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY); } /// @@ -78,19 +77,19 @@ public static bool InitSavedGames() try { Logger.Log("Writing spawn.ini for saved game."); - SafePath.DeleteFileIfExists(ProgramConstants.GamePath, SAVED_GAMES_DIRECTORY, "spawnSG.ini"); - File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini"), SafePath.CombineFilePath(ProgramConstants.GamePath, SAVED_GAMES_DIRECTORY, "spawnSG.ini")); + SafePath.DeleteFileIfExists(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI); + File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS), SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI)); } catch (Exception ex) { - Logger.Log("Writing spawn.ini for saved game failed! Exception message: " + ex.Message); + ProgramConstants.LogException(ex, "Writing spawn.ini for saved game failed!"); return false; } return true; } - public static void RenameSavedGame() + public static async ValueTask RenameSavedGameAsync() { Logger.Log("Renaming saved game."); @@ -140,7 +139,7 @@ public static void RenameSavedGame() } catch (Exception ex) { - Logger.Log("Renaming saved game failed! Exception message: " + ex.Message); + ProgramConstants.LogException(ex, "Renaming saved game failed!"); } tryCount++; @@ -151,7 +150,7 @@ public static void RenameSavedGame() return; } - System.Threading.Thread.Sleep(250); + await Task.Delay(250).ConfigureAwait(false); } saveRenameInProgress = false; @@ -159,7 +158,7 @@ public static void RenameSavedGame() Logger.Log("Saved game SAVEGAME.NET succesfully renamed to " + Path.GetFileName(sgPath)); } - public static bool EraseSavedGames() + private static bool EraseSavedGames() { Logger.Log("Erasing previous MP saved games."); @@ -172,7 +171,7 @@ public static bool EraseSavedGames() } catch (Exception ex) { - Logger.Log("Erasing previous MP saved games failed! Exception message: " + ex.Message); + ProgramConstants.LogException(ex, "Erasing previous MP saved games failed!"); return false; } @@ -180,4 +179,4 @@ public static bool EraseSavedGames() return true; } } -} +} \ No newline at end of file diff --git a/ClientCore/Settings/UserINISettings.cs b/ClientCore/Settings/UserINISettings.cs index 671c823fe..8ea4c919c 100644 --- a/ClientCore/Settings/UserINISettings.cs +++ b/ClientCore/Settings/UserINISettings.cs @@ -99,6 +99,10 @@ protected UserINISettings(IniFile iniFile) EnableMapSharing = new BoolSetting(iniFile, MULTIPLAYER, "EnableMapSharing", true); AlwaysDisplayTunnelList = new BoolSetting(iniFile, MULTIPLAYER, "AlwaysDisplayTunnelList", false); MapSortState = new IntSetting(iniFile, MULTIPLAYER, "MapSortState", (int)SortDirection.None); + UseLegacyTunnels = new BoolSetting(iniFile, MULTIPLAYER, "UseLegacyTunnels", false); + UseP2P = new BoolSetting(iniFile, MULTIPLAYER, "UseP2P", false); + UseDynamicTunnels = new BoolSetting(iniFile, MULTIPLAYER, "UseDynamicTunnels", true); + EnableReplays = new BoolSetting(iniFile, MULTIPLAYER, "EnableReplays", false); CheckForUpdates = new BoolSetting(iniFile, OPTIONS, "CheckforUpdates", true); @@ -134,24 +138,40 @@ protected UserINISettings(IniFile iniFile) /*********/ public IntSetting IngameScreenWidth { get; private set; } + public IntSetting IngameScreenHeight { get; private set; } + public StringSetting ClientTheme { get; private set; } + public string ThemeFolderPath => ClientConfiguration.Instance.GetThemePath(ClientTheme); + public StringSetting Translation { get; private set; } + public string TranslationFolderPath => SafePath.CombineDirectoryPath( ClientConfiguration.Instance.TranslationsFolderPath, Translation); + public string TranslationThemeFolderPath => SafePath.CombineDirectoryPath( ClientConfiguration.Instance.TranslationsFolderPath, Translation, ClientConfiguration.Instance.GetThemePath(ClientTheme)); + public IntSetting DetailLevel { get; private set; } + public StringSetting Renderer { get; private set; } + public BoolSetting WindowedMode { get; private set; } + public BoolSetting BorderlessWindowedMode { get; private set; } + public BoolSetting BackBufferInVRAM { get; private set; } + public IntSetting ClientResolutionX { get; set; } + public IntSetting ClientResolutionY { get; set; } + public BoolSetting BorderlessWindowedClient { get; private set; } + public IntSetting ClientFPS { get; private set; } + public BoolSetting DisplayToggleableExtraTextures { get; private set; } /*********/ @@ -159,12 +179,19 @@ protected UserINISettings(IniFile iniFile) /*********/ public DoubleSetting ScoreVolume { get; private set; } + public DoubleSetting SoundVolume { get; private set; } + public DoubleSetting VoiceVolume { get; private set; } + public BoolSetting IsScoreShuffle { get; private set; } + public DoubleSetting ClientVolume { get; private set; } + public BoolSetting PlayMainMenuMusic { get; private set; } + public BoolSetting StopMusicOnMenu { get; private set; } + public BoolSetting MessageSound { get; private set; } /********/ @@ -172,8 +199,11 @@ protected UserINISettings(IniFile iniFile) /********/ public IntSetting ScrollRate { get; private set; } + public IntSetting DragDistance { get; private set; } + public IntSetting DoubleTapInterval { get; private set; } + public StringSetting Win8CompatMode { get; private set; } /************************/ @@ -183,15 +213,23 @@ protected UserINISettings(IniFile iniFile) public StringSetting PlayerName { get; private set; } public IntSetting ChatColor { get; private set; } + public IntSetting LANChatColor { get; private set; } + public BoolSetting PingUnofficialCnCNetTunnels { get; private set; } + public BoolSetting WritePathToRegistry { get; private set; } + public BoolSetting PlaySoundOnGameHosted { get; private set; } public BoolSetting SkipConnectDialog { get; private set; } + public BoolSetting PersistentMode { get; private set; } + public BoolSetting AutomaticCnCNetLogin { get; private set; } + public BoolSetting DiscordIntegration { get; private set; } + public BoolSetting AllowGameInvitesFromFriendsOnly { get; private set; } public BoolSetting NotifyOnUserListChange { get; private set; } @@ -206,6 +244,12 @@ protected UserINISettings(IniFile iniFile) public IntSetting MapSortState { get; private set; } + public BoolSetting UseLegacyTunnels { get; private set; } + + public BoolSetting UseP2P { get; private set; } + + public BoolSetting UseDynamicTunnels { get; private set; } + /*********************/ /* GAME LIST FILTERS */ /*********************/ @@ -229,7 +273,9 @@ protected UserINISettings(IniFile iniFile) public BoolSetting CheckForUpdates { get; private set; } public BoolSetting PrivacyPolicyAccepted { get; private set; } + public BoolSetting IsFirstRun { get; private set; } + public BoolSetting CustomComponentsDenied { get; private set; } public IntSetting Difficulty { get; private set; } @@ -250,6 +296,8 @@ protected UserINISettings(IniFile iniFile) public BoolSetting GenerateOnlyNewValuesInTranslationStub { get; private set; } + public BoolSetting EnableReplays { get; private set; } + public StringListSetting FavoriteMaps { get; private set; } public void SetValue(string section, string key, string value) diff --git a/ClientCore/Statistics/DataWriter.cs b/ClientCore/Statistics/DataWriter.cs index fe58fce62..95cb87b32 100644 --- a/ClientCore/Statistics/DataWriter.cs +++ b/ClientCore/Statistics/DataWriter.cs @@ -1,27 +1,22 @@ using System; using System.IO; using System.Text; +using System.Threading.Tasks; namespace ClientCore.Statistics { internal static class DataWriter { - public static void WriteInt(this Stream stream, int value) - { - stream.Write(BitConverter.GetBytes(value), 0, sizeof(int)); - } + public static Task WriteIntAsync(this Stream stream, int value) + => stream.WriteAsync(BitConverter.GetBytes(value), 0, sizeof(int)); - public static void WriteLong(this Stream stream, long value) - { - stream.Write(BitConverter.GetBytes(value), 0, sizeof(long)); - } + public static Task WriteLongAsync(this Stream stream, long value) + => stream.WriteAsync(BitConverter.GetBytes(value), 0, sizeof(long)); - public static void WriteBool(this Stream stream, bool value) - { - stream.WriteByte(Convert.ToByte(value)); - } + public static Task WriteBoolAsync(this Stream stream, bool value) + => stream.WriteAsync(new[] { Convert.ToByte(value) }, 0, 1); - public static void WriteString(this Stream stream, string value, int reservedSpace, Encoding encoding = null) + public static Task WriteStringAsync(this Stream stream, string value, int reservedSpace, Encoding encoding = null) { if (encoding == null) encoding = Encoding.Unicode; @@ -37,7 +32,7 @@ public static void WriteString(this Stream stream, string value, int reservedSpa writeBuffer[j] = temp[j]; } - stream.Write(writeBuffer, 0, writeBuffer.Length); + return stream.WriteAsync(writeBuffer, 0, writeBuffer.Length); } } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/GameParsers/LogFileStatisticsParser.cs b/ClientCore/Statistics/GameParsers/LogFileStatisticsParser.cs index a507bd560..c677185f9 100644 --- a/ClientCore/Statistics/GameParsers/LogFileStatisticsParser.cs +++ b/ClientCore/Statistics/GameParsers/LogFileStatisticsParser.cs @@ -1,30 +1,27 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using Rampastring.Tools; namespace ClientCore.Statistics.GameParsers { public class LogFileStatisticsParser : GenericMatchParser { - public LogFileStatisticsParser(MatchStatistics ms, bool isLoadedGame) : base(ms) + public LogFileStatisticsParser(MatchStatistics ms, bool isLoadedGame) + : base(ms) { this.isLoadedGame = isLoadedGame; } - private string fileName = "DTA.log"; private string economyString = "Economy"; // RA2/YR do not have economy stat, but a number of built objects. - private bool isLoadedGame; + private readonly bool isLoadedGame; - public void ParseStats(string gamepath, string fileName) + public async ValueTask ParseStatisticsAsync(string gamepath, string fileName) { - this.fileName = fileName; - if (ClientConfiguration.Instance.UseBuiltStatistic) economyString = "Built"; - ParseStatistics(gamepath); - } + if (ClientConfiguration.Instance.UseBuiltStatistic) + economyString = "Built"; - protected override void ParseStatistics(string gamepath) - { FileInfo statisticsFileInfo = SafePath.GetFile(gamepath, fileName); if (!statisticsFileInfo.Exists) @@ -37,7 +34,7 @@ protected override void ParseStatistics(string gamepath) try { - using StreamReader reader = new StreamReader(statisticsFileInfo.OpenRead()); + using var reader = new StreamReader(statisticsFileInfo.OpenRead()); string line; @@ -47,13 +44,13 @@ protected override void ParseStatistics(string gamepath) bool sawCompletion = false; int numPlayersFound = 0; - while ((line = reader.ReadLine()) != null) + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { if (line.Contains(": Loser")) { // Player found, game saw completion sawCompletion = true; - string playerName = line.Substring(0, line.Length - 7); + string playerName = line[..^7]; currentPlayer = Statistics.GetEmptyPlayerByName(playerName); if (isLoadedGame && currentPlayer == null) @@ -67,7 +64,7 @@ protected override void ParseStatistics(string gamepath) // The player has been taken over by an AI during the match Logger.Log("Losing take-over AI found"); takeoverAIs.Add(new PlayerStatistics("Computer", false, true, false, 0, 10, 255, 1)); - currentPlayer = takeoverAIs[takeoverAIs.Count - 1]; + currentPlayer = takeoverAIs[^1]; } if (currentPlayer != null) @@ -77,7 +74,7 @@ protected override void ParseStatistics(string gamepath) { // Player found, game saw completion sawCompletion = true; - string playerName = line.Substring(0, line.Length - 8); + string playerName = line[..^8]; currentPlayer = Statistics.GetEmptyPlayerByName(playerName); if (isLoadedGame && currentPlayer == null) @@ -91,7 +88,7 @@ protected override void ParseStatistics(string gamepath) // The player has been taken over by an AI during the match Logger.Log("Winning take-over AI found"); takeoverAIs.Add(new PlayerStatistics("Computer", false, true, false, 0, 10, 255, 1)); - currentPlayer = takeoverAIs[takeoverAIs.Count - 1]; + currentPlayer = takeoverAIs[^1]; } if (currentPlayer != null) @@ -103,23 +100,23 @@ protected override void ParseStatistics(string gamepath) else if (line.Contains("Game loop finished. Average FPS")) { // Game loop finished. Average FPS = - string fpsString = line.Substring(34); + string fpsString = line[34..]; Statistics.AverageFPS = Int32.Parse(fpsString); } if (currentPlayer == null || line.Length < 1) continue; - line = line.Substring(1); + line = line[1..]; if (line.StartsWith("Lost = ")) - currentPlayer.Losses = Int32.Parse(line.Substring(7)); + currentPlayer.Losses = Int32.Parse(line[7..]); else if (line.StartsWith("Kills = ")) - currentPlayer.Kills = Int32.Parse(line.Substring(8)); + currentPlayer.Kills = Int32.Parse(line[8..]); else if (line.StartsWith("Score = ")) - currentPlayer.Score = Int32.Parse(line.Substring(8)); + currentPlayer.Score = Int32.Parse(line[8..]); else if (line.StartsWith(economyString + " = ")) - currentPlayer.Economy = Int32.Parse(line.Substring(economyString.Length + 2)); + currentPlayer.Economy = Int32.Parse(line[(economyString.Length + 2)..]); } // Check empty players for take-over by AIs @@ -150,8 +147,8 @@ protected override void ParseStatistics(string gamepath) } catch (Exception ex) { - Logger.Log("DTAStatisticsParser: Error parsing statistics from match! Message: " + ex.Message); + ProgramConstants.LogException(ex, "DTAStatisticsParser: Error parsing statistics from match!"); } } } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/GenericMatchParser.cs b/ClientCore/Statistics/GenericMatchParser.cs index bd12beccd..e1c4d363a 100644 --- a/ClientCore/Statistics/GenericMatchParser.cs +++ b/ClientCore/Statistics/GenericMatchParser.cs @@ -2,13 +2,11 @@ { public abstract class GenericMatchParser { - public MatchStatistics Statistics {get; set;} + protected MatchStatistics Statistics {get; set;} - public GenericMatchParser(MatchStatistics ms) + protected GenericMatchParser(MatchStatistics ms) { Statistics = ms; } - - protected abstract void ParseStatistics(string gamepath); } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/GenericStatisticsManager.cs b/ClientCore/Statistics/GenericStatisticsManager.cs index c87cb5699..86e4e141b 100644 --- a/ClientCore/Statistics/GenericStatisticsManager.cs +++ b/ClientCore/Statistics/GenericStatisticsManager.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; namespace ClientCore.Statistics { @@ -8,25 +8,20 @@ public abstract class GenericStatisticsManager { protected List Statistics = new List(); - protected static string GetStatDatabaseVersion(string scorePath) + protected static async ValueTask GetStatDatabaseVersionAsync(string scorePath) { if (!File.Exists(scorePath)) { return null; } - using (StreamReader reader = new StreamReader(scorePath)) - { - char[] versionBuffer = new char[4]; - reader.Read(versionBuffer, 0, versionBuffer.Length); + using var reader = new StreamReader(scorePath); + char[] versionBuffer = new char[4]; + await reader.ReadAsync(versionBuffer, 0, versionBuffer.Length).ConfigureAwait(false); - String s = new String(versionBuffer); - return s; - } + return new string(versionBuffer); } - public abstract void ReadStatistics(string gamePath); - public int GetMatchCount() { return Statistics.Count; } public MatchStatistics GetMatchByIndex(int index) @@ -34,4 +29,4 @@ public MatchStatistics GetMatchByIndex(int index) return Statistics[index]; } } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/MatchStatistics.cs b/ClientCore/Statistics/MatchStatistics.cs index 4e49b7f97..699ab1f40 100644 --- a/ClientCore/Statistics/MatchStatistics.cs +++ b/ClientCore/Statistics/MatchStatistics.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Threading.Tasks; using ClientCore.Statistics.GameParsers; using Rampastring.Tools; @@ -49,7 +50,7 @@ public MatchStatistics(string gameVersion, int gameId, string mapName, string ga public void AddPlayer(string name, bool isLocal, bool isAI, bool isSpectator, int side, int team, int color, int aiLevel) { - PlayerStatistics ps = new PlayerStatistics(name, isLocal, isAI, isSpectator, + PlayerStatistics ps = new PlayerStatistics(name, isLocal, isAI, isSpectator, side, team, color, aiLevel); Players.Add(ps); } @@ -59,14 +60,14 @@ public void AddPlayer(PlayerStatistics ps) Players.Add(ps); } - public void ParseStatistics(string gamePath, string gameName, bool isLoadedGame) + public ValueTask ParseStatisticsAsync(string gamePath, bool isLoadedGame) { Logger.Log("Parsing game statistics."); LengthInSeconds = (int)(DateTime.Now - DateAndTime).TotalSeconds; var parser = new LogFileStatisticsParser(this, isLoadedGame); - parser.ParseStats(gamePath, ClientConfiguration.Instance.StatisticsLogFileName); + return parser.ParseStatisticsAsync(gamePath, ClientConfiguration.Instance.StatisticsLogFileName); } public PlayerStatistics GetEmptyPlayerByName(string playerName) @@ -101,37 +102,37 @@ public PlayerStatistics GetPlayer(int index) return Players[index]; } - public void Write(Stream stream) + public async ValueTask WriteAsync(Stream stream) { // Game length - stream.WriteInt(LengthInSeconds); + await stream.WriteIntAsync(LengthInSeconds).ConfigureAwait(false); // Game version, 8 bytes, ASCII - stream.WriteString(GameVersion, 8, Encoding.ASCII); + await stream.WriteStringAsync(GameVersion, 8, Encoding.ASCII).ConfigureAwait(false); // Date and time, 8 bytes - stream.WriteLong(DateAndTime.ToBinary()); + await stream.WriteLongAsync(DateAndTime.ToBinary()).ConfigureAwait(false); // SawCompletion, 1 byte - stream.WriteBool(SawCompletion); + await stream.WriteBoolAsync(SawCompletion).ConfigureAwait(false); // Number of players, 1 byte - stream.WriteByte(Convert.ToByte(GetPlayerCount())); + await stream.WriteAsync(new[] { Convert.ToByte(GetPlayerCount()) }, 0, 1).ConfigureAwait(false); // Average FPS, 4 bytes - stream.WriteInt(AverageFPS); + await stream.WriteIntAsync(AverageFPS).ConfigureAwait(false); // Map name, 128 bytes (64 chars), Unicode - stream.WriteString(MapName, 128); + await stream.WriteStringAsync(MapName, 128).ConfigureAwait(false); // Game mode, 64 bytes (32 chars), Unicode - stream.WriteString(GameMode, 64); + await stream.WriteStringAsync(GameMode, 64).ConfigureAwait(false); // Unique game ID, 4 bytes - stream.WriteInt(GameID); + await stream.WriteIntAsync(GameID).ConfigureAwait(false); // Whether game options were valid for earning a star, 1 byte - stream.WriteBool(IsValidForStar); + await stream.WriteBoolAsync(IsValidForStar).ConfigureAwait(false); // Write player info for (int i = 0; i < GetPlayerCount(); i++) { PlayerStatistics ps = GetPlayer(i); - ps.Write(stream); + await ps.WriteAsync(stream).ConfigureAwait(false); } } } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/PlayerStatistics.cs b/ClientCore/Statistics/PlayerStatistics.cs index 770acc7d6..b97660e87 100644 --- a/ClientCore/Statistics/PlayerStatistics.cs +++ b/ClientCore/Statistics/PlayerStatistics.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading.Tasks; namespace ClientCore.Statistics { @@ -7,7 +8,7 @@ public class PlayerStatistics { public PlayerStatistics() { } - public PlayerStatistics(string name, bool isLocal, bool isAi, bool isSpectator, + public PlayerStatistics(string name, bool isLocal, bool isAi, bool isSpectator, int side, int team, int color, int aiLevel) { Name = name; @@ -22,7 +23,7 @@ public PlayerStatistics(string name, bool isLocal, bool isAi, bool isSpectator, public string Name { get; set; } public int Kills { get; set; } - public int Losses {get; set;} + public int Losses { get; set; } public int Economy { get; set; } public int Score { get; set; } public int Side { get; set; } @@ -35,35 +36,35 @@ public PlayerStatistics(string name, bool isLocal, bool isAi, bool isSpectator, public bool IsAI { get; set; } public int Color { get; set; } = 255; - public void Write(Stream stream) + public async ValueTask WriteAsync(Stream stream) { - stream.WriteInt(Economy); + await stream.WriteIntAsync(Economy).ConfigureAwait(false); // 1 byte for IsAI - stream.WriteBool(IsAI); + await stream.WriteBoolAsync(IsAI).ConfigureAwait(false); // 1 byte for IsLocalPlayer - stream.WriteBool(IsLocalPlayer); + await stream.WriteBoolAsync(IsLocalPlayer).ConfigureAwait(false); // 4 bytes for kills - stream.Write(BitConverter.GetBytes(Kills), 0, 4); + await stream.WriteAsync(BitConverter.GetBytes(Kills), 0, 4).ConfigureAwait(false); // 4 bytes for losses - stream.Write(BitConverter.GetBytes(Losses), 0, 4); + await stream.WriteAsync(BitConverter.GetBytes(Losses), 0, 4).ConfigureAwait(false); // Name takes 32 bytes - stream.WriteString(Name, 32); + await stream.WriteStringAsync(Name, 32).ConfigureAwait(false); // 1 byte for SawEnd - stream.WriteBool(SawEnd); + await stream.WriteBoolAsync(SawEnd).ConfigureAwait(false); // 4 bytes for Score - stream.WriteInt(Score); + await stream.WriteIntAsync(Score).ConfigureAwait(false); // 1 byte for Side - stream.WriteByte(Convert.ToByte(Side)); + await stream.WriteAsync(new[] { Convert.ToByte(Side) }, 0, 1).ConfigureAwait(false); // 1 byte for Team - stream.WriteByte(Convert.ToByte(Team)); + await stream.WriteAsync(new[] { Convert.ToByte(Team) }, 0, 1).ConfigureAwait(false); // 1 byte color Color - stream.WriteByte(Convert.ToByte(Color)); + await stream.WriteAsync(new[] { Convert.ToByte(Color) }, 0, 1).ConfigureAwait(false); // 1 byte for WasSpectator - stream.WriteBool(WasSpectator); + await stream.WriteBoolAsync(WasSpectator).ConfigureAwait(false); // 1 byte for Won - stream.WriteBool(Won); + await stream.WriteBoolAsync(Won).ConfigureAwait(false); // 1 byte for AI level - stream.WriteByte(Convert.ToByte(AILevel)); + await stream.WriteAsync(new[] { Convert.ToByte(AILevel) }, 0, 1).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/ClientCore/Statistics/StatisticsManager.cs b/ClientCore/Statistics/StatisticsManager.cs index 787ee730c..3b9b09d6f 100644 --- a/ClientCore/Statistics/StatisticsManager.cs +++ b/ClientCore/Statistics/StatisticsManager.cs @@ -3,6 +3,7 @@ using System.Text; using System.IO; using System.Linq; +using System.Threading.Tasks; using Rampastring.Tools; namespace ClientCore.Statistics @@ -16,7 +17,6 @@ public class StatisticsManager : GenericStatisticsManager public event EventHandler GameAdded; - public static StatisticsManager Instance { get @@ -27,7 +27,7 @@ public static StatisticsManager Instance } } - public override void ReadStatistics(string gamePath) + public async ValueTask ReadStatisticsAsync(string gamePath) { FileInfo scoreFileInfo = SafePath.GetFile(gamePath, SCORE_FILE_PATH); @@ -42,10 +42,10 @@ public override void ReadStatistics(string gamePath) Statistics.Clear(); FileInfo oldScoreFileInfo = SafePath.GetFile(gamePath, OLD_SCORE_FILE_PATH); - bool resave = ReadFile(oldScoreFileInfo.FullName); - bool resaveNew = ReadFile(scoreFileInfo.FullName); + bool resave = await ReadFileAsync(oldScoreFileInfo.FullName).ConfigureAwait(false); + bool resaveNew = await ReadFileAsync(scoreFileInfo.FullName).ConfigureAwait(false); - PurgeStats(); + await PurgeStatsAsync().ConfigureAwait(false); if (resave || resaveNew) { @@ -55,7 +55,7 @@ public override void ReadStatistics(string gamePath) SafePath.DeleteFileIfExists(oldScoreFileInfo.FullName); } - SaveDatabase(); + await SaveDatabaseAsync().ConfigureAwait(false); } } @@ -64,13 +64,13 @@ public override void ReadStatistics(string gamePath) /// /// The path to the statistics file. /// A bool that determines whether the database should be re-saved. - private bool ReadFile(string filePath) + private async ValueTask ReadFileAsync(string filePath) { bool returnValue = false; try { - string databaseVersion = GetStatDatabaseVersion(filePath); + string databaseVersion = await GetStatDatabaseVersionAsync(filePath).ConfigureAwait(false); if (databaseVersion == null) return false; // No score database exists @@ -79,27 +79,27 @@ private bool ReadFile(string filePath) { case "1.00": case "1.01": - ReadDatabase(filePath, 0); + await ReadDatabaseAsync(filePath, 0).ConfigureAwait(false); returnValue = true; break; case "1.02": - ReadDatabase(filePath, 2); + await ReadDatabaseAsync(filePath, 2).ConfigureAwait(false); returnValue = true; break; case "1.03": - ReadDatabase(filePath, 3); + await ReadDatabaseAsync(filePath, 3).ConfigureAwait(false); returnValue = true; break; case "1.04": - ReadDatabase(filePath, 4); + await ReadDatabaseAsync(filePath, 4).ConfigureAwait(false); returnValue = true; break; case "1.05": - ReadDatabase(filePath, 5); + await ReadDatabaseAsync(filePath, 5).ConfigureAwait(false); returnValue = true; break; case "1.06": - ReadDatabase(filePath, 6); + await ReadDatabaseAsync(filePath, 6).ConfigureAwait(false); break; default: throw new InvalidDataException("Invalid version for " + filePath + ": " + databaseVersion); @@ -107,23 +107,25 @@ private bool ReadFile(string filePath) } catch (Exception ex) { - Logger.Log("Error reading statistics: " + ex.Message); + ProgramConstants.LogException(ex, "Error reading statistics."); } return returnValue; } - private void ReadDatabase(string filePath, int version) + private async ValueTask ReadDatabaseAsync(string filePath, int version) { // TODO split this function with the MatchStatistics and PlayerStatistics classes try { - using (FileStream fs = File.OpenRead(filePath)) + FileStream fs = File.OpenRead(filePath); + + await using (fs.ConfigureAwait(false)) { fs.Position = 4; // Skip version byte[] readBuffer = new byte[128]; - fs.Read(readBuffer, 0, 4); // First 4 bytes following the version mean the amount of games + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); // First 4 bytes following the version mean the amount of games int gameCount = BitConverter.ToInt32(readBuffer, 0); for (int i = 0; i < gameCount; i++) @@ -131,26 +133,26 @@ private void ReadDatabase(string filePath, int version) MatchStatistics ms = new MatchStatistics(); // First 4 bytes of game info is the length in seconds - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); int lengthInSeconds = BitConverter.ToInt32(readBuffer, 0); ms.LengthInSeconds = lengthInSeconds; // Next 8 are the game version - fs.Read(readBuffer, 0, 8); + await fs.ReadAsync(readBuffer, 0, 8).ConfigureAwait(false); ms.GameVersion = System.Text.Encoding.ASCII.GetString(readBuffer, 0, 8); // Then comes the date and time, also 8 bytes - fs.Read(readBuffer, 0, 8); + await fs.ReadAsync(readBuffer, 0, 8).ConfigureAwait(false); long dateData = BitConverter.ToInt64(readBuffer, 0); ms.DateAndTime = DateTime.FromBinary(dateData); // Then one byte for SawCompletion - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ms.SawCompletion = Convert.ToBoolean(readBuffer[0]); // Then 1 byte for the amount of players - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); int playerCount = readBuffer[0]; if (version > 0) { // 4 bytes for average FPS - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ms.AverageFPS = BitConverter.ToInt32(readBuffer, 0); } @@ -162,23 +164,23 @@ private void ReadDatabase(string filePath, int version) } // Map name, 64 or 128 bytes of Unicode depending on version - fs.Read(readBuffer, 0, mapNameLength); + await fs.ReadAsync(readBuffer, 0, mapNameLength).ConfigureAwait(false); ms.MapName = Encoding.Unicode.GetString(readBuffer).Replace("\0", ""); // Game mode, 64 bytes - fs.Read(readBuffer, 0, 64); + await fs.ReadAsync(readBuffer, 0, 64).ConfigureAwait(false); ms.GameMode = Encoding.Unicode.GetString(readBuffer, 0, 64).Replace("\0", ""); if (version > 2) { // Unique game ID, 32 bytes (int32) - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ms.GameID = BitConverter.ToInt32(readBuffer, 0); } if (version > 5) { - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ms.IsValidForStar = Convert.ToBoolean(readBuffer[0]); } @@ -190,58 +192,58 @@ private void ReadDatabase(string filePath, int version) if (version > 4) { // Economy is shared for the Built stat in YR - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ps.Economy = BitConverter.ToInt32(readBuffer, 0); } else { // Economy is between 0 and 100 in old versions, so it takes only one byte - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.Economy = readBuffer[0]; } // IsAI is a bool, so obviously one byte - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.IsAI = Convert.ToBoolean(readBuffer[0]); // IsLocalPlayer is also a bool - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.IsLocalPlayer = Convert.ToBoolean(readBuffer[0]); // Kills take 4 bytes - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ps.Kills = BitConverter.ToInt32(readBuffer, 0); // Losses also take 4 bytes - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ps.Losses = BitConverter.ToInt32(readBuffer, 0); // 32 bytes for the name - fs.Read(readBuffer, 0, 32); + await fs.ReadAsync(readBuffer, 0, 32).ConfigureAwait(false); ps.Name = System.Text.Encoding.Unicode.GetString(readBuffer, 0, 32); ps.Name = ps.Name.Replace("\0", String.Empty); // 1 byte for SawEnd - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.SawEnd = Convert.ToBoolean(readBuffer[0]); // 4 bytes for Score - fs.Read(readBuffer, 0, 4); + await fs.ReadAsync(readBuffer, 0, 4).ConfigureAwait(false); ps.Score = BitConverter.ToInt32(readBuffer, 0); // 1 byte for Side - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.Side = readBuffer[0]; // 1 byte for Team - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.Team = readBuffer[0]; if (version > 2) { // 1 byte for Color - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.Color = readBuffer[0]; } // 1 byte for WasSpectator - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.WasSpectator = Convert.ToBoolean(readBuffer[0]); // 1 byte for Won - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.Won = Convert.ToBoolean(readBuffer[0]); // 1 byte for AI level - fs.Read(readBuffer, 0, 1); + await fs.ReadAsync(readBuffer, 0, 1).ConfigureAwait(false); ps.AILevel = readBuffer[0]; ms.AddPlayer(ps); @@ -259,11 +261,11 @@ private void ReadDatabase(string filePath, int version) } catch (Exception ex) { - Logger.Log("Reading the statistics file failed! Message: " + ex.Message); + ProgramConstants.LogException(ex, "Reading the statistics file failed!"); } } - public void PurgeStats() + private async ValueTask PurgeStatsAsync() { int removedCount = 0; @@ -279,16 +281,10 @@ public void PurgeStats() } if (removedCount > 0) - SaveDatabase(); - } - - public void ClearDatabase() - { - Statistics.Clear(); - CreateDummyFile(); + await SaveDatabaseAsync().ConfigureAwait(false); } - public void AddMatchAndSaveDatabase(bool addMatch, MatchStatistics ms) + public async ValueTask AddMatchAndSaveDatabaseAsync(bool addMatch, MatchStatistics ms) { // Skip adding stats if the game only had one player, make exception for co-op since it doesn't recognize pre-placed houses as players. if (ms.GetPlayerCount() <= 1 && !ms.MapIsCoop) @@ -313,56 +309,62 @@ public void AddMatchAndSaveDatabase(bool addMatch, MatchStatistics ms) if (!scoreFileInfo.Exists) { - CreateDummyFile(); + await CreateDummyFileAsync().ConfigureAwait(false); } Logger.Log("Writing game info to statistics file."); - using (FileStream fs = scoreFileInfo.Open(FileMode.Open, FileAccess.ReadWrite)) + FileStream fs = scoreFileInfo.Open(FileMode.Open, FileAccess.ReadWrite); + + await using (fs.ConfigureAwait(false)) { fs.Position = 4; // First 4 bytes after the version mean the amount of games - fs.WriteInt(Statistics.Count); + await fs.WriteIntAsync(Statistics.Count).ConfigureAwait(false); fs.Position = fs.Length; - ms.Write(fs); + await ms.WriteAsync(fs).ConfigureAwait(false); } Logger.Log("Finished writing statistics."); } - private void CreateDummyFile() + private static async ValueTask CreateDummyFileAsync() { Logger.Log("Creating empty statistics file."); - using StreamWriter sw = new StreamWriter(SafePath.GetFile(ProgramConstants.GamePath, SCORE_FILE_PATH).Create()); - sw.Write(VERSION); + var sw = new StreamWriter(SafePath.GetFile(ProgramConstants.GamePath, SCORE_FILE_PATH).Create()); + + await using (sw.ConfigureAwait(false)) + { + await sw.WriteAsync(VERSION).ConfigureAwait(false); + } } /// /// Deletes the statistics file on the file system and rewrites it. /// - public void SaveDatabase() + public async ValueTask SaveDatabaseAsync() { FileInfo scoreFileInfo = SafePath.GetFile(ProgramConstants.GamePath, SCORE_FILE_PATH); SafePath.DeleteFileIfExists(scoreFileInfo.FullName); - CreateDummyFile(); + await CreateDummyFileAsync().ConfigureAwait(false); - using (FileStream fs = scoreFileInfo.Open(FileMode.Open, FileAccess.ReadWrite)) + FileStream fs = scoreFileInfo.Open(FileMode.Open, FileAccess.ReadWrite); + + await using (fs.ConfigureAwait(false)) { fs.Position = 4; // First 4 bytes after the version mean the amount of games - fs.WriteInt(Statistics.Count); + await fs.WriteIntAsync(Statistics.Count).ConfigureAwait(false); foreach (MatchStatistics ms in Statistics) { - ms.Write(fs); + await ms.WriteAsync(fs).ConfigureAwait(false); } } } public bool HasBeatCoOpMap(string mapName, string gameMode) { - List matches = new List(); - // Filter out unfitting games foreach (MatchStatistics ms in Statistics) { @@ -412,7 +414,7 @@ public int GetCoopRankForDefaultMap(string mapName, int requiredPlayerCount) return rank; } - int GetRankForCoopMatch(MatchStatistics ms) + private static int GetRankForCoopMatch(MatchStatistics ms) { PlayerStatistics localPlayer = ms.Players.Find(p => p.IsLocalPlayer); @@ -485,8 +487,6 @@ int GetRankForCoopMatch(MatchStatistics ms) public bool HasWonMapInPvP(string mapName, string gameMode, int requiredPlayerCount) { - List matches = new List(); - foreach (MatchStatistics ms in Statistics) { if (!ms.SawCompletion) @@ -663,15 +663,9 @@ public int GetSkirmishRankForDefaultMap(string mapName, int requiredPlayerCount) return rank; } - public bool IsGameIdUnique(int gameId) - { - return Statistics.Find(m => m.GameID == gameId) == null; - } - public MatchStatistics GetMatchWithGameID(int gameId) { return Statistics.Find(m => m.GameID == gameId); } - } -} +} \ No newline at end of file diff --git a/ClientGUI/ClientGUI.csproj b/ClientGUI/ClientGUI.csproj index 3e41f5b84..12deca6c5 100644 --- a/ClientGUI/ClientGUI.csproj +++ b/ClientGUI/ClientGUI.csproj @@ -4,11 +4,8 @@ CnCNet Client UI Library CnCNet CnCNet Client - Copyright © CnCNet, Rampastring 2011-2022 + Copyright © CnCNet, Rampastring 2011-2023 CnCNet - 2.1.0.1 - 2.1.0.1 - 2.1.0.1 ClientGUI ClientGUI @@ -17,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ClientGUI/GameProcessLogic.cs b/ClientGUI/GameProcessLogic.cs index d725da2f8..a12227201 100644 --- a/ClientGUI/GameProcessLogic.cs +++ b/ClientGUI/GameProcessLogic.cs @@ -5,7 +5,7 @@ using ClientCore; using Rampastring.Tools; using ClientCore.INIProcessing; -using System.Threading; +using System.Threading.Tasks; using Rampastring.XNAUI; namespace ClientGUI @@ -27,7 +27,7 @@ public static class GameProcessLogic /// /// Starts the main game process. /// - public static void StartGameProcess(WindowManager windowManager) + public static async ValueTask StartGameProcessAsync(WindowManager windowManager) { Logger.Log("About to launch main game executable."); @@ -36,7 +36,7 @@ public static void StartGameProcess(WindowManager windowManager) int waitTimes = 0; while (PreprocessorBackgroundTask.Instance.IsRunning) { - Thread.Sleep(1000); + await Task.Delay(1000).ConfigureAwait(false); waitTimes++; if (waitTimes > 10) { @@ -48,17 +48,20 @@ public static void StartGameProcess(WindowManager windowManager) } OSVersion osVersion = ClientConfiguration.Instance.GetOperatingSystemVersion(); - string gameExecutableName; string additionalExecutableName = string.Empty; - if (osVersion == OSVersion.UNIX) + if (osVersion is OSVersion.UNIX) + { gameExecutableName = ClientConfiguration.Instance.UnixGameExecutableName; + } else { string launcherExecutableName = ClientConfiguration.Instance.GameLauncherExecutableName; if (string.IsNullOrEmpty(launcherExecutableName)) + { gameExecutableName = ClientConfiguration.Instance.GetGameExecutableName(); + } else { gameExecutableName = launcherExecutableName; @@ -94,7 +97,7 @@ public static void StartGameProcess(WindowManager windowManager) } catch (Exception ex) { - Logger.Log("Error launching QRes: " + ex.Message); + ProgramConstants.LogException(ex, "Error launching QRes"); XNAMessageBox.Show(windowManager, "Error launching game", "Error launching " + ProgramConstants.QRES_EXECUTABLE + ". Please check that your anti-virus isn't blocking the CnCNet Client. " + "You can also try running the client as an administrator." + Environment.NewLine + Environment.NewLine + "You are unable to participate in this match." + Environment.NewLine + Environment.NewLine + "Returned error: " + ex.Message); @@ -103,7 +106,7 @@ public static void StartGameProcess(WindowManager windowManager) } if (Environment.ProcessorCount > 1 && SingleCoreAffinity) - QResProcess.ProcessorAffinity = (IntPtr)2; + QResProcess.ProcessorAffinity = 2; } else { @@ -133,7 +136,7 @@ public static void StartGameProcess(WindowManager windowManager) } catch (Exception ex) { - Logger.Log("Error launching " + gameFileInfo.Name + ": " + ex.Message); + ProgramConstants.LogException(ex, "Error launching " + gameFileInfo.Name); XNAMessageBox.Show(windowManager, "Error launching game", "Error launching " + gameFileInfo.Name + ". Please check that your anti-virus isn't blocking the CnCNet Client. " + "You can also try running the client as an administrator." + Environment.NewLine + Environment.NewLine + "You are unable to participate in this match." + Environment.NewLine + Environment.NewLine + "Returned error: " + ex.Message); @@ -149,17 +152,18 @@ public static void StartGameProcess(WindowManager windowManager) } GameProcessStarted?.Invoke(); - Logger.Log("Waiting for qres.dat or " + gameExecutableName + " to exit."); } - static void Process_Exited(object sender, EventArgs e) + private static void Process_Exited(object sender, EventArgs e) { Logger.Log("GameProcessLogic: Process exited."); - Process proc = (Process)sender; + + using var proc = (Process)sender; + proc.Exited -= Process_Exited; - proc.Dispose(); + GameProcessExited?.Invoke(); } } -} +} \ No newline at end of file diff --git a/ClientGUI/Parser.cs b/ClientGUI/Parser.cs index bd23ca5b3..476822201 100644 --- a/ClientGUI/Parser.cs +++ b/ClientGUI/Parser.cs @@ -1,24 +1,4 @@ -/********************************************************************* -* Dawn of the Tiberium Age MonoGame/XNA CnCNet Client -* Expression Parser -* Copyright (C) Rampastring 2022 -* -* The CnCNet Client is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* The CnCNet Client is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program.If not, see. -* -*********************************************************************/ - -using ClientCore; +using ClientCore; using Rampastring.Tools; using Rampastring.XNAUI; using Rampastring.XNAUI.XNAControls; diff --git a/ClientGUI/XNAClientPreferredItemDropDown.cs b/ClientGUI/XNAClientPreferredItemDropDown.cs index 815c571e1..eadceb546 100644 --- a/ClientGUI/XNAClientPreferredItemDropDown.cs +++ b/ClientGUI/XNAClientPreferredItemDropDown.cs @@ -60,7 +60,7 @@ public override void Draw(GameTime gameTime) PreferredItemIndexes.ForEach(i => { XNADropDownItem preferredItem = Items[i]; - preferredItem.Text = preferredItem.Text.Substring(0, preferredItem.Text.Length - PreferredItemLabel.Length - 1); + preferredItem.Text = preferredItem.Text[..(preferredItem.Text.Length - PreferredItemLabel.Length - 1)]; }); } else diff --git a/DTAConfig/DTAConfig.csproj b/DTAConfig/DTAConfig.csproj index a401e6a18..be4f83a67 100644 --- a/DTAConfig/DTAConfig.csproj +++ b/DTAConfig/DTAConfig.csproj @@ -4,18 +4,15 @@ CnCNet Config Library CnCNet CnCNet Client - Copyright © CnCNet, Rampastring 2011-2022 + Copyright © CnCNet, Rampastring 2011-2023 CnCNet - 2.2.0.0 - 2.2.0.0 - 2.2.0.0 DTAConfig DTAConfig - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/DTAConfig/HotkeyConfigurationWindow.cs b/DTAConfig/HotkeyConfigurationWindow.cs index f80f629dc..fa655c3e3 100644 --- a/DTAConfig/HotkeyConfigurationWindow.cs +++ b/DTAConfig/HotkeyConfigurationWindow.cs @@ -288,7 +288,7 @@ private void HotkeyConfigurationWindow_EnabledChanged(object sender, EventArgs e /// private void GameProcessLogic_GameProcessExited() { - WindowManager.AddCallback(new Action(LoadKeyboardINI), null); + WindowManager.AddCallback(LoadKeyboardINI); } private void LoadKeyboardINI() diff --git a/DTAConfig/OptionPanels/ComponentsPanel.cs b/DTAConfig/OptionPanels/ComponentsPanel.cs index 4448796a3..4fe670311 100644 --- a/DTAConfig/OptionPanels/ComponentsPanel.cs +++ b/DTAConfig/OptionPanels/ComponentsPanel.cs @@ -205,7 +205,7 @@ public void InstallComponent(int id) /// The current download progress percentage. private void cc_DownloadProgressChanged(CustomComponent c, int percentage) { - WindowManager.AddCallback(new Action(HandleDownloadProgressChanged), c, percentage); + WindowManager.AddCallback(() => HandleDownloadProgressChanged(c, percentage)); } private void HandleDownloadProgressChanged(CustomComponent cc, int percentage) @@ -227,7 +227,7 @@ private void HandleDownloadProgressChanged(CustomComponent cc, int percentage) /// True if the download succeeded, otherwise false. private void cc_DownloadFinished(CustomComponent c, bool success) { - WindowManager.AddCallback(new Action(HandleDownloadFinished), c, success); + WindowManager.AddCallback(() => HandleDownloadFinished(c, success)); } private void HandleDownloadFinished(CustomComponent cc, bool success) diff --git a/DTAConfig/OptionPanels/DisplayOptionsPanel.cs b/DTAConfig/OptionPanels/DisplayOptionsPanel.cs index f69439ad2..bdb26855f 100644 --- a/DTAConfig/OptionPanels/DisplayOptionsPanel.cs +++ b/DTAConfig/OptionPanels/DisplayOptionsPanel.cs @@ -441,10 +441,13 @@ private void MessageBox_NoClicked(XNAMessageBox messageBox) } catch (Exception ex) { - Logger.Log("Setting TSCompatFixDeclined failed! Returned error: " + ex.Message); + ProgramConstants.LogException(ex, "Setting TSCompatFixDeclined failed!"); } } - catch { } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } } [SupportedOSPlatform("windows")] @@ -478,7 +481,7 @@ private void BtnGameCompatibilityFix_LeftClick(object sender, EventArgs e) } catch (Exception ex) { - Logger.Log("Uninstalling DTA/TI/TS Compatibility Fix failed. Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Uninstalling DTA/TI/TS Compatibility Fix failed."); XNAMessageBox.Show(WindowManager, "Uninstalling Compatibility Fix Failed".L10N("Client:DTAConfig:TSFixUninstallFailTitle"), "Uninstalling DTA/TI/TS Compatibility Fix failed. Returned error:".L10N("Client:DTAConfig:TSFixUninstallFailText") + " " + ex.Message); } @@ -506,7 +509,7 @@ private void BtnGameCompatibilityFix_LeftClick(object sender, EventArgs e) } catch (Exception ex) { - Logger.Log("Installing DTA/TI/TS Compatibility Fix failed. Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Installing DTA/TI/TS Compatibility Fix failed."); XNAMessageBox.Show(WindowManager, "Installing Compatibility Fix Failed".L10N("Client:DTAConfig:TSFixInstallFailTitle"), "Installing DTA/TI/TS Compatibility Fix failed. Error message:".L10N("Client:DTAConfig:TSFixInstallFailText") + " " + ex.Message); } @@ -537,7 +540,7 @@ private void BtnMapEditorCompatibilityFix_LeftClick(object sender, EventArgs e) } catch (Exception ex) { - Logger.Log("Uninstalling FinalSun Compatibility Fix failed. Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Uninstalling FinalSun Compatibility Fix failed."); XNAMessageBox.Show(WindowManager, "Uninstalling Compatibility Fix Failed".L10N("Client:DTAConfig:TSFinalSunFixUninstallFailedTitle"), "Uninstalling FinalSun Compatibility Fix failed. Error message:".L10N("Client:DTAConfig:TSFinalSunFixUninstallFailedText") + " " + ex.Message); } @@ -565,7 +568,7 @@ private void BtnMapEditorCompatibilityFix_LeftClick(object sender, EventArgs e) } catch (Exception ex) { - Logger.Log("Installing FinalSun Compatibility Fix failed. Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Installing FinalSun Compatibility Fix failed."); XNAMessageBox.Show(WindowManager, "Installing Compatibility Fix Failed".L10N("Client:DTAConfig:TSFinalSunCompatibilityFixInstalledFailedTitle"), "Installing FinalSun Compatibility Fix failed. Error message:".L10N("Client:DTAConfig:TSFinalSunCompatibilityFixInstalledFailedText") + " " + ex.Message); } diff --git a/DTAConfig/OptionsWindow.cs b/DTAConfig/OptionsWindow.cs index 60a2ac597..4b7718e9a 100644 --- a/DTAConfig/OptionsWindow.cs +++ b/DTAConfig/OptionsWindow.cs @@ -191,7 +191,7 @@ private void SaveSettings() } catch (Exception ex) { - Logger.Log("Saving settings failed! Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Saving settings failed!"); XNAMessageBox.Show(WindowManager, "Saving Settings Failed".L10N("Client:DTAConfig:SaveSettingFailTitle"), "Saving settings failed! Error message:".L10N("Client:DTAConfig:SaveSettingFailText") + " " + ex.Message); } diff --git a/DXClient.sln b/DXClient.sln index c28cff82e..00dd34b13 100644 --- a/DXClient.sln +++ b/DXClient.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.2.32408.312 @@ -15,10 +14,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig build\AfterPublish.targets = build\AfterPublish.targets + .github\workflows\build.yml = .github\workflows\build.yml build\CopyResources.targets = build\CopyResources.targets Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets build\Framework.props = build\Framework.props + .github\workflows\pr-build-comment.yml = .github\workflows\pr-build-comment.yml + README.md = README.md build\VSCompatibleLayer.props = build\VSCompatibleLayer.props build\WinForms.props = build\WinForms.props EndProjectSection @@ -870,21 +872,15 @@ Global {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsGLRelease|x86.ActiveCfg = AresWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsGLRelease|x86.Build.0 = AresWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|Any CPU.ActiveCfg = AresWindowsXNADebug|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|Any CPU.Build.0 = AresWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|ARM64.ActiveCfg = AresWindowsXNADebug|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|ARM64.Build.0 = AresWindowsXNADebug|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x64.ActiveCfg = AresWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x64.Build.0 = AresWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x86.ActiveCfg = AresWindowsXNADebug|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x86.Build.0 = AresWindowsXNADebug|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x86.ActiveCfg = AresWindowsXNADebug|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNADebug|x86.Build.0 = AresWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|Any CPU.ActiveCfg = AresWindowsXNARelease|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|Any CPU.Build.0 = AresWindowsXNARelease|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|ARM64.ActiveCfg = AresWindowsXNARelease|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|ARM64.Build.0 = AresWindowsXNARelease|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x64.ActiveCfg = AresWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x64.Build.0 = AresWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x86.ActiveCfg = AresWindowsXNARelease|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x86.Build.0 = AresWindowsXNARelease|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x86.ActiveCfg = AresWindowsXNARelease|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.AresWindowsXNARelease|x86.Build.0 = AresWindowsXNARelease|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSUniversalGLDebug|Any CPU.ActiveCfg = TSUniversalGLDebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSUniversalGLDebug|Any CPU.Build.0 = TSUniversalGLDebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSUniversalGLDebug|ARM64.ActiveCfg = TSUniversalGLDebug|ARM64 @@ -934,21 +930,15 @@ Global {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsGLRelease|x86.ActiveCfg = TSWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsGLRelease|x86.Build.0 = TSWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|Any CPU.ActiveCfg = TSWindowsXNADebug|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|Any CPU.Build.0 = TSWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|ARM64.ActiveCfg = TSWindowsXNADebug|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|ARM64.Build.0 = TSWindowsXNADebug|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x64.ActiveCfg = TSWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x64.Build.0 = TSWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x86.ActiveCfg = TSWindowsXNADebug|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x86.Build.0 = TSWindowsXNADebug|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x86.ActiveCfg = TSWindowsXNADebug|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNADebug|x86.Build.0 = TSWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|Any CPU.ActiveCfg = TSWindowsXNARelease|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|Any CPU.Build.0 = TSWindowsXNARelease|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|ARM64.ActiveCfg = TSWindowsXNARelease|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|ARM64.Build.0 = TSWindowsXNARelease|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x64.ActiveCfg = TSWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x64.Build.0 = TSWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x86.ActiveCfg = TSWindowsXNARelease|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x86.Build.0 = TSWindowsXNARelease|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x86.ActiveCfg = TSWindowsXNARelease|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.TSWindowsXNARelease|x86.Build.0 = TSWindowsXNARelease|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRUniversalGLDebug|Any CPU.ActiveCfg = YRUniversalGLDebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRUniversalGLDebug|Any CPU.Build.0 = YRUniversalGLDebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRUniversalGLDebug|ARM64.ActiveCfg = YRUniversalGLDebug|ARM64 @@ -998,21 +988,15 @@ Global {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsGLRelease|x86.ActiveCfg = YRWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsGLRelease|x86.Build.0 = YRWindowsGLRelease|x86 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|Any CPU.ActiveCfg = YRWindowsXNADebug|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|Any CPU.Build.0 = YRWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|ARM64.ActiveCfg = YRWindowsXNADebug|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|ARM64.Build.0 = YRWindowsXNADebug|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x64.ActiveCfg = YRWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x64.Build.0 = YRWindowsXNADebug|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x86.ActiveCfg = YRWindowsXNADebug|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x86.Build.0 = YRWindowsXNADebug|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x86.ActiveCfg = YRWindowsXNADebug|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNADebug|x86.Build.0 = YRWindowsXNADebug|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|Any CPU.ActiveCfg = YRWindowsXNARelease|Any CPU - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|Any CPU.Build.0 = YRWindowsXNARelease|Any CPU {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|ARM64.ActiveCfg = YRWindowsXNARelease|ARM64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|ARM64.Build.0 = YRWindowsXNARelease|ARM64 {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x64.ActiveCfg = YRWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x64.Build.0 = YRWindowsXNARelease|x64 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x86.ActiveCfg = YRWindowsXNARelease|x86 - {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x86.Build.0 = YRWindowsXNARelease|x86 + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x86.ActiveCfg = YRWindowsXNARelease|Any CPU + {E0412313-0A6F-400B-9EC8-B162DA8AAA0E}.YRWindowsXNARelease|x86.Build.0 = YRWindowsXNARelease|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DXMainClient/DXGUI/GameClass.cs b/DXMainClient/DXGUI/GameClass.cs index 98a56753f..008a5ef49 100644 --- a/DXMainClient/DXGUI/GameClass.cs +++ b/DXMainClient/DXGUI/GameClass.cs @@ -10,7 +10,7 @@ using Rampastring.Tools; using Rampastring.XNAUI; using System; -using ClientGUI; +using ClientCore.Extensions; using DTAClient.Domain.Multiplayer; using DTAClient.Domain.Multiplayer.CnCNet; using DTAClient.DXGUI.Multiplayer; @@ -25,7 +25,6 @@ using MainMenu = DTAClient.DXGUI.Generic.MainMenu; #if DX || (GL && WINFORMS) using System.Diagnostics; -using System.IO; #endif #if WINFORMS using System.Windows.Forms; @@ -59,7 +58,7 @@ protected override void Initialize() string windowTitle = ClientConfiguration.Instance.WindowTitle; Window.Title = string.IsNullOrEmpty(windowTitle) ? - string.Format("{0} Client", MainClientConstants.GAME_NAME_SHORT) : windowTitle; + string.Format("{0} Client", ProgramConstants.GAME_NAME_SHORT) : windowTitle; base.Initialize(); @@ -86,41 +85,34 @@ protected override void Initialize() _ = AssetLoader.LoadTextureUncached("checkBoxClear.png"); } - catch (Exception ex) + catch (Exception ex) when (ex.Message.Contains("DeviceRemoved")) { - if (ex.Message.Contains("DeviceRemoved")) - { - Logger.Log($"Creating texture on startup failed! Creating {startupFailureFile} file and re-launching client launcher."); + ProgramConstants.LogException(ex, $"Creating texture on startup failed! Creating {startupFailureFile} file and re-launching client launcher."); - DirectoryInfo clientDirectory = SafePath.GetDirectory(ProgramConstants.ClientUserFilesPath); + DirectoryInfo clientDirectory = SafePath.GetDirectory(ProgramConstants.ClientUserFilesPath); - if (!clientDirectory.Exists) - clientDirectory.Create(); + if (!clientDirectory.Exists) + clientDirectory.Create(); - // Create startup failure file that the launcher can check for this error - // and handle it by redirecting the user to another version instead + // Create startup failure file that the launcher can check for this error + // and handle it by redirecting the user to another version instead + File.WriteAllBytesAsync(SafePath.CombineFilePath(clientDirectory.FullName, startupFailureFile), new byte[] { 1 }).HandleTask(); - File.WriteAllBytes(SafePath.CombineFilePath(clientDirectory.FullName, startupFailureFile), new byte[] { 1 }); + string launcherExe = ClientConfiguration.Instance.LauncherExe; + if (string.IsNullOrEmpty(launcherExe)) + { + // LauncherExe is unspecified, just throw the exception forward + // because we can't handle it + Logger.Log("No LauncherExe= specified in ClientDefinitions.ini! " + + "Forwarding exception to regular exception handler."); - string launcherExe = ClientConfiguration.Instance.LauncherExe; - if (string.IsNullOrEmpty(launcherExe)) - { - // LauncherExe is unspecified, just throw the exception forward - // because we can't handle it + throw; + } - Logger.Log("No LauncherExe= specified in ClientDefinitions.ini! " + - "Forwarding exception to regular exception handler."); + Logger.Log("Starting " + launcherExe + " and exiting."); - throw; - } - else - { - Logger.Log("Starting " + launcherExe + " and exiting."); - - Process.Start(SafePath.CombineFilePath(ProgramConstants.GamePath, launcherExe)); - Environment.Exit(1); - } - } + using var _ = Process.Start(SafePath.CombineFilePath(ProgramConstants.GamePath, launcherExe)); + Environment.Exit(1); } #endif @@ -173,14 +165,14 @@ protected override void Initialize() if (UserINISettings.Instance.AutoRemoveUnderscoresFromName) { while (playerName.EndsWith("_")) - playerName = playerName.Substring(0, playerName.Length - 1); + playerName = playerName[..^1]; } if (string.IsNullOrEmpty(playerName)) { playerName = Environment.UserName; - playerName = playerName.Substring(playerName.IndexOf("\\") + 1); + playerName = playerName[(playerName.IndexOf("\\") + 1)..]; } playerName = Renderer.GetSafeString(NameValidator.GetValidOfflineName(playerName), 0); @@ -189,8 +181,14 @@ protected override void Initialize() UserINISettings.Instance.PlayerName.Value = playerName; IServiceProvider serviceProvider = BuildServiceProvider(wm); + CnCNetUserData cncNetUserData = serviceProvider.GetService(); + + cncNetUserData.InitializeAsync().HandleTask(); + LoadingScreen ls = serviceProvider.GetService(); + wm.AddAndInitializeControl(ls); + ls.ClientRectangle = new Rectangle((wm.RenderResolutionX - ls.Width) / 2, (wm.RenderResolutionY - ls.Height) / 2, ls.Width, ls.Height); } @@ -264,6 +262,8 @@ private IServiceProvider BuildServiceProvider(WindowManager windowManager) ) .Build(); + windowManager.GameClosing += (_, _) => { host.Dispose(); }; + return host.Services.GetService(); } diff --git a/DXMainClient/DXGUI/Generic/CampaignSelector.cs b/DXMainClient/DXGUI/Generic/CampaignSelector.cs index 9aae7c6bf..6b568bece 100644 --- a/DXMainClient/DXGUI/Generic/CampaignSelector.cs +++ b/DXMainClient/DXGUI/Generic/CampaignSelector.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using DTAClient.Domain; using System.IO; +using System.Threading.Tasks; +using ClientCore.Extensions; using ClientGUI; using Rampastring.XNAUI.XNAControls; using Rampastring.XNAUI; @@ -149,7 +151,7 @@ public override void Initialize() btnLaunch.ClientRectangle = new Rectangle(12, Height - 35, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnLaunch.Text = "Launch".L10N("Client:Main:ButtonLaunch"); btnLaunch.AllowClick = false; - btnLaunch.LeftClick += BtnLaunch_LeftClick; + btnLaunch.LeftClick += (_, _) => BtnLaunch_LeftClickAsync().HandleTask(); var btnCancel = new XNAClientButton(WindowManager); btnCancel.Name = "btnCancel"; @@ -186,7 +188,7 @@ public override void Initialize() AddChild(dp); dp.CenterOnParent(); cheaterWindow.CenterOnParent(); - cheaterWindow.YesClicked += CheaterWindow_YesClicked; + cheaterWindow.YesClicked += (_, _) => LaunchMissionAsync(missionToLaunch).HandleTask(); cheaterWindow.Disable(); } @@ -224,7 +226,7 @@ private void BtnCancel_LeftClick(object sender, EventArgs e) Enabled = false; } - private void BtnLaunch_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLaunch_LeftClickAsync() { int selectedMissionId = lbCampaignList.SelectedIndex; @@ -239,7 +241,7 @@ private void BtnLaunch_LeftClick(object sender, EventArgs e) return; } - LaunchMission(mission); + await LaunchMissionAsync(mission).ConfigureAwait(false); } private bool AreFilesModified() @@ -253,65 +255,60 @@ private bool AreFilesModified() return false; } - /// - /// Called when the user wants to proceed to the mission despite having - /// being called a cheater. - /// - private void CheaterWindow_YesClicked(object sender, EventArgs e) - { - LaunchMission(missionToLaunch); - } - /// /// Starts a singleplayer mission. /// - private void LaunchMission(Mission mission) + private async ValueTask LaunchMissionAsync(Mission mission) { bool copyMapsToSpawnmapINI = ClientConfiguration.Instance.CopyMissionsToSpawnmapINI; - Logger.Log("About to write spawn.ini."); - using var spawnStreamWriter = new StreamWriter(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini")); - spawnStreamWriter.WriteLine("; Generated by DTA Client"); - spawnStreamWriter.WriteLine("[Settings]"); - if (copyMapsToSpawnmapINI) - spawnStreamWriter.WriteLine("Scenario=spawnmap.ini"); - else - spawnStreamWriter.WriteLine("Scenario=" + mission.Scenario); + Logger.Log($"About to write {ProgramConstants.SPAWNER_SETTINGS}."); + var spawnStreamWriter = new StreamWriter(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS)); - // No one wants to play missions on Fastest, so we'll change it to Faster - if (UserINISettings.Instance.GameSpeed == 0) - UserINISettings.Instance.GameSpeed.Value = 1; + await using (spawnStreamWriter.ConfigureAwait(false)) + { + await spawnStreamWriter.WriteLineAsync("; Generated by DTA Client").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("[Settings]").ConfigureAwait(false); + if (copyMapsToSpawnmapINI) + await spawnStreamWriter.WriteLineAsync($"Scenario={ProgramConstants.SPAWNMAP_INI}").ConfigureAwait(false); + else + await spawnStreamWriter.WriteLineAsync("Scenario=" + mission.Scenario).ConfigureAwait(false); + + // No one wants to play missions on Fastest, so we'll change it to Faster + if (UserINISettings.Instance.GameSpeed == 0) + UserINISettings.Instance.GameSpeed.Value = 1; - spawnStreamWriter.WriteLine("CampaignID=" + mission.Index); - spawnStreamWriter.WriteLine("GameSpeed=" + UserINISettings.Instance.GameSpeed); + await spawnStreamWriter.WriteLineAsync("CampaignID=" + mission.Index).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("GameSpeed=" + UserINISettings.Instance.GameSpeed).ConfigureAwait(false); #if YR || ARES - spawnStreamWriter.WriteLine("Ra2Mode=" + !mission.RequiredAddon); + await spawnStreamWriter.WriteLineAsync("Ra2Mode=" + !mission.RequiredAddon).ConfigureAwait(false); #else - spawnStreamWriter.WriteLine("Firestorm=" + mission.RequiredAddon); + await spawnStreamWriter.WriteLineAsync("Firestorm=" + mission.RequiredAddon).ConfigureAwait(false); #endif - spawnStreamWriter.WriteLine("CustomLoadScreen=" + LoadingScreenController.GetLoadScreenName(mission.Side.ToString())); - spawnStreamWriter.WriteLine("IsSinglePlayer=Yes"); - spawnStreamWriter.WriteLine("SidebarHack=" + ClientConfiguration.Instance.SidebarHack); - spawnStreamWriter.WriteLine("Side=" + mission.Side); - spawnStreamWriter.WriteLine("BuildOffAlly=" + mission.BuildOffAlly); - - UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; - - spawnStreamWriter.WriteLine("DifficultyModeHuman=" + (mission.PlayerAlwaysOnNormalDifficulty ? "1" : trbDifficultySelector.Value.ToString())); - spawnStreamWriter.WriteLine("DifficultyModeComputer=" + GetComputerDifficulty()); + await spawnStreamWriter.WriteLineAsync("Firestorm=" + mission.RequiredAddon).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("CustomLoadScreen=" + LoadingScreenController.GetLoadScreenName(mission.Side.ToString())).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("IsSinglePlayer=Yes").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("SidebarHack=" + ClientConfiguration.Instance.SidebarHack).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("Side=" + mission.Side).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("BuildOffAlly=" + mission.BuildOffAlly).ConfigureAwait(false); + + UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; + + await spawnStreamWriter.WriteLineAsync("DifficultyModeHuman=" + (mission.PlayerAlwaysOnNormalDifficulty ? "1" : trbDifficultySelector.Value.ToString())).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("DifficultyModeComputer=" + GetComputerDifficulty()).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync().ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync().ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync().ConfigureAwait(false); + } var difficultyIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, DifficultyIniPaths[trbDifficultySelector.Value])); string difficultyName = DifficultyNames[trbDifficultySelector.Value]; - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); - if (copyMapsToSpawnmapINI) { var mapIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, mission.Scenario)); IniFile.ConsolidateIniFiles(mapIni, difficultyIni); - mapIni.WriteIniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawnmap.ini")); + mapIni.WriteIniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SPAWNMAP_INI)); } UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; @@ -322,7 +319,7 @@ private void LaunchMission(Mission mission) discordHandler.UpdatePresence(mission.UntranslatedGUIName, difficultyName, mission.IconPath, true); GameProcessLogic.GameProcessExited += GameProcessExited_Callback; - GameProcessLogic.StartGameProcess(WindowManager); + await GameProcessLogic.StartGameProcessAsync(WindowManager).ConfigureAwait(false); } private int GetComputerDifficulty() => @@ -330,7 +327,7 @@ private int GetComputerDifficulty() => private void GameProcessExited_Callback() { - WindowManager.AddCallback(new Action(GameProcessExited), null); + WindowManager.AddCallback(GameProcessExited); } protected virtual void GameProcessExited() diff --git a/DXMainClient/DXGUI/Generic/ExtrasWindow.cs b/DXMainClient/DXGUI/Generic/ExtrasWindow.cs index 8a72062d2..88d425aed 100644 --- a/DXMainClient/DXGUI/Generic/ExtrasWindow.cs +++ b/DXMainClient/DXGUI/Generic/ExtrasWindow.cs @@ -80,7 +80,7 @@ private void BtnExMapEditor_LeftClick(object sender, EventArgs e) private void BtnExCredits_LeftClick(object sender, EventArgs e) { - ProcessLauncher.StartShellProcess(MainClientConstants.CREDITS_URL); + ProcessLauncher.StartShellProcess(ProgramConstants.CREDITS_URL); } private void BtnExCancel_LeftClick(object sender, EventArgs e) diff --git a/DXMainClient/DXGUI/Generic/GameInProgressWindow.cs b/DXMainClient/DXGUI/Generic/GameInProgressWindow.cs index dad31dc45..20e676315 100644 --- a/DXMainClient/DXGUI/Generic/GameInProgressWindow.cs +++ b/DXMainClient/DXGUI/Generic/GameInProgressWindow.cs @@ -9,6 +9,7 @@ using Color = Microsoft.Xna.Framework.Color; using Rectangle = Microsoft.Xna.Framework.Rectangle; #if ARES +using ClientCore.Extensions; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; @@ -87,15 +88,18 @@ public override void Initialize() if (debugLogFileInfo.Exists) debugLogLastWriteTime = debugLogFileInfo.LastWriteTime; } - catch { } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } #endif } private void SharedUILogic_GameProcessStarted() { - #if ARES debugSnapshotDirectories = GetAllDebugSnapshotDirectories(); + #else try { @@ -108,7 +112,7 @@ private void SharedUILogic_GameProcessStarted() } catch (Exception ex) { - Logger.Log("Exception when deleting error log files! Message: " + ex.Message); + ProgramConstants.LogException(ex, "Exception when deleting error log files!"); deletingLogFilesFailed = true; } #endif @@ -129,7 +133,7 @@ private void SharedUILogic_GameProcessStarted() private void SharedUILogic_GameProcessExited() { - AddCallback(new Action(HandleGameProcessExited), null); + AddCallback(HandleGameProcessExited); } private void HandleGameProcessExited() @@ -166,7 +170,7 @@ private void HandleGameProcessExited() DateTime dtn = DateTime.Now; #if ARES - Task.Factory.StartNew(ProcessScreenshots); + ProcessScreenshotsAsync().HandleTask(); // TODO: Ares debug log handling should be addressed in Ares DLL itself. // For now the following are handled here: @@ -178,7 +182,7 @@ private void HandleGameProcessExited() string snapshotDirectory = GetNewestDebugSnapshotDirectory(); bool snapshotCreated = snapshotDirectory != null; - snapshotDirectory = snapshotDirectory ?? SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "debug", FormattableString.Invariant($"snapshot-{dtn.ToString("yyyyMMdd-HHmmss")}")); + snapshotDirectory ??= SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "debug", FormattableString.Invariant($"snapshot-{dtn.ToString("yyyyMMdd-HHmmss")}")); bool debugLogModified = false; FileInfo debugLogFileInfo = SafePath.GetFile(ProgramConstants.GamePath, "debug", "debug.log"); @@ -246,8 +250,9 @@ private bool CopyErrorLog(string directory, string filename, DateTime? dateTime) } catch (Exception ex) { - Logger.Log("An error occured while checking for " + filename + " file. Message: " + ex.Message); + ProgramConstants.LogException(ex, "An error occurred while checking for " + filename + " file."); } + return copied; } @@ -290,8 +295,9 @@ private bool CopySyncErrorLogs(string directory, DateTime? dateTime) } catch (Exception ex) { - Logger.Log("An error occured while checking for SYNCX.TXT files. Message: " + ex.Message); + ProgramConstants.LogException(ex, "An error occured while checking for SYNCX.TXT files."); } + return copied; } @@ -319,7 +325,10 @@ private string GetNewestDebugSnapshotDirectory() { Directory.Delete(directory); } - catch { } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } } } } @@ -339,7 +348,10 @@ private List GetAllDebugSnapshotDirectories() { directories.AddRange(Directory.GetDirectories(SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "debug"), "snapshot-*")); } - catch { } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } return directories; } @@ -347,7 +359,7 @@ private List GetAllDebugSnapshotDirectories() /// /// Converts BMP screenshots to PNG and copies them from game directory to Screenshots sub-directory. /// - private void ProcessScreenshots() + private static async ValueTask ProcessScreenshotsAsync() { IEnumerable files = SafePath.GetDirectory(ProgramConstants.GamePath).EnumerateFiles("SCRN*.bmp"); DirectoryInfo screenshotsDirectory = SafePath.GetDirectory(ProgramConstants.GamePath, "Screenshots"); @@ -360,7 +372,7 @@ private void ProcessScreenshots() } catch (Exception ex) { - Logger.Log("ProcessScreenshots: An error occured trying to create Screenshots directory. Message: " + ex.Message); + ProgramConstants.LogException(ex, "ProcessScreenshots: An error occured trying to create Screenshots directory."); return; } } @@ -369,16 +381,23 @@ private void ProcessScreenshots() { try { - using FileStream stream = file.OpenRead(); - using var image = Image.Load(stream); - FileInfo newFile = SafePath.GetFile(screenshotsDirectory.FullName, FormattableString.Invariant($"{Path.GetFileNameWithoutExtension(file.FullName)}.png")); - using FileStream newFileStream = newFile.OpenWrite(); + FileStream stream = file.OpenRead(); - image.SaveAsPng(newFileStream); + await using (stream.ConfigureAwait(false)) + { + using Image image = await Image.LoadAsync(stream).ConfigureAwait(false); + FileInfo newFile = SafePath.GetFile(screenshotsDirectory.FullName, FormattableString.Invariant($"{Path.GetFileNameWithoutExtension(file.FullName)}.png")); + FileStream newFileStream = newFile.OpenWrite(); + + await using (newFileStream.ConfigureAwait(false)) + { + await image.SaveAsPngAsync(newFileStream).ConfigureAwait(false); + } + } } catch (Exception ex) { - Logger.Log("ProcessScreenshots: Error occured when trying to save " + Path.GetFileNameWithoutExtension(file.FullName) + ".png. Message: " + ex.Message); + ProgramConstants.LogException(ex, "ProcessScreenshots: Error occured when trying to save " + Path.GetFileNameWithoutExtension(file.FullName) + ".png."); continue; } diff --git a/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs b/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs index c087dd38a..6c1051609 100644 --- a/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs +++ b/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs @@ -10,6 +10,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Generic { @@ -18,8 +20,6 @@ namespace DTAClient.DXGUI.Generic /// public class GameLoadingWindow : XNAWindow { - private const string SAVED_GAMES_DIRECTORY = "Saved Games"; - public GameLoadingWindow(WindowManager windowManager, DiscordHandler discordHandler) : base(windowManager) { this.discordHandler = discordHandler; @@ -57,7 +57,7 @@ public override void Initialize() btnLaunch.ClientRectangle = new Rectangle(125, 345, 110, 23); btnLaunch.Text = "Load".L10N("Client:Main:ButtonLoad"); btnLaunch.AllowClick = false; - btnLaunch.LeftClick += BtnLaunch_LeftClick; + btnLaunch.LeftClick += (_, _) => BtnLaunch_LeftClickAsync().HandleTask(); btnDelete = new XNAClientButton(WindowManager); btnDelete.Name = nameof(btnDelete); @@ -101,7 +101,7 @@ private void BtnCancel_LeftClick(object sender, EventArgs e) Enabled = false; } - private void BtnLaunch_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLaunch_LeftClickAsync() { SavedGame sg = savedGames[lbSaveGameList.SelectedIndex]; Logger.Log("Loading saved game " + sg.FileName); @@ -111,35 +111,43 @@ private void BtnLaunch_LeftClick(object sender, EventArgs e) if (spawnerSettingsFile.Exists) spawnerSettingsFile.Delete(); - using StreamWriter spawnStreamWriter = new StreamWriter(spawnerSettingsFile.FullName); - spawnStreamWriter.WriteLine("; generated by DTA Client"); - spawnStreamWriter.WriteLine("[Settings]"); - spawnStreamWriter.WriteLine("Scenario=spawnmap.ini"); - spawnStreamWriter.WriteLine("SaveGameName=" + sg.FileName); - spawnStreamWriter.WriteLine("LoadSaveGame=Yes"); - spawnStreamWriter.WriteLine("SidebarHack=" + ClientConfiguration.Instance.SidebarHack); - spawnStreamWriter.WriteLine("CustomLoadScreen=" + LoadingScreenController.GetLoadScreenName("g")); - spawnStreamWriter.WriteLine("Firestorm=No"); - spawnStreamWriter.WriteLine("GameSpeed=" + UserINISettings.Instance.GameSpeed); - spawnStreamWriter.WriteLine(); + var spawnStreamWriter = new StreamWriter(spawnerSettingsFile.FullName); + + await using (spawnStreamWriter.ConfigureAwait(false)) + { + await spawnStreamWriter.WriteLineAsync("; generated by DTA Client").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("[Settings]").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync($"Scenario={ProgramConstants.SPAWNMAP_INI}").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("SaveGameName=" + sg.FileName).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("LoadSaveGame=Yes").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("SidebarHack=" + ClientConfiguration.Instance.SidebarHack).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("CustomLoadScreen=" + LoadingScreenController.GetLoadScreenName("g")).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("Firestorm=No").ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync("GameSpeed=" + UserINISettings.Instance.GameSpeed).ConfigureAwait(false); + await spawnStreamWriter.WriteLineAsync().ConfigureAwait(false); + } - FileInfo spawnMapIniFile = SafePath.GetFile(ProgramConstants.GamePath, "spawnmap.ini"); + FileInfo spawnMapIniFile = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNMAP_INI); if (spawnMapIniFile.Exists) spawnMapIniFile.Delete(); - using StreamWriter spawnMapStreamWriter = new StreamWriter(spawnMapIniFile.FullName); - spawnMapStreamWriter.WriteLine("[Map]"); - spawnMapStreamWriter.WriteLine("Size=0,0,50,50"); - spawnMapStreamWriter.WriteLine("LocalSize=0,0,50,50"); - spawnMapStreamWriter.WriteLine(); + var spawnMapStreamWriter = new StreamWriter(spawnMapIniFile.FullName); + + await using (spawnStreamWriter.ConfigureAwait(false)) + { + await spawnMapStreamWriter.WriteLineAsync("[Map]").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync("Size=0,0,50,50").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync("LocalSize=0,0,50,50").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync().ConfigureAwait(false); + } discordHandler.UpdatePresence(sg.GUIName, true); Enabled = false; GameProcessLogic.GameProcessExited += GameProcessExited_Callback; - GameProcessLogic.StartGameProcess(WindowManager); + await GameProcessLogic.StartGameProcessAsync(WindowManager).ConfigureAwait(false); } private void BtnDelete_LeftClick(object sender, EventArgs e) @@ -162,13 +170,13 @@ private void DeleteMsgBox_YesClicked(XNAMessageBox obj) SavedGame sg = savedGames[lbSaveGameList.SelectedIndex]; Logger.Log("Deleting saved game " + sg.FileName); - SafePath.DeleteFileIfExists(ProgramConstants.GamePath, SAVED_GAMES_DIRECTORY, sg.FileName); + SafePath.DeleteFileIfExists(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY, sg.FileName); ListSaves(); } private void GameProcessExited_Callback() { - WindowManager.AddCallback(new Action(GameProcessExited), null); + WindowManager.AddCallback(GameProcessExited); } protected virtual void GameProcessExited() @@ -183,7 +191,7 @@ public void ListSaves() lbSaveGameList.ClearItems(); lbSaveGameList.SelectedIndex = -1; - DirectoryInfo savedGamesDirectoryInfo = SafePath.GetDirectory(ProgramConstants.GamePath, SAVED_GAMES_DIRECTORY); + DirectoryInfo savedGamesDirectoryInfo = SafePath.GetDirectory(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY); if (!savedGamesDirectoryInfo.Exists) { @@ -219,4 +227,4 @@ private void ParseSaveGame(string fileName) savedGames.Add(sg); } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Generic/LoadingScreen.cs b/DXMainClient/DXGUI/Generic/LoadingScreen.cs index fe8bc84ce..bc81ce424 100644 --- a/DXMainClient/DXGUI/Generic/LoadingScreen.cs +++ b/DXMainClient/DXGUI/Generic/LoadingScreen.cs @@ -2,12 +2,10 @@ using System.Threading.Tasks; using ClientCore; using ClientCore.CnCNet5; +using ClientCore.Extensions; using ClientGUI; using ClientUpdater; using DTAClient.Domain.Multiplayer; -using DTAClient.DXGUI.Multiplayer; -using DTAClient.DXGUI.Multiplayer.CnCNet; -using DTAClient.DXGUI.Multiplayer.GameLobby; using DTAClient.Online; using Microsoft.Extensions.DependencyInjection; using Microsoft.Xna.Framework; @@ -16,7 +14,7 @@ namespace DTAClient.DXGUI.Generic { - public class LoadingScreen : XNAWindow + internal class LoadingScreen : XNAWindow { public LoadingScreen( CnCNetManager cncnetManager, @@ -30,14 +28,8 @@ MapLoader mapLoader this.mapLoader = mapLoader; } - private static readonly object locker = new object(); - private MapLoader mapLoader; - - private PrivateMessagingPanel privateMessagingPanel; - private bool visibleSpriteCursor; - private Task updaterInitTask; private Task mapLoadTask; private readonly CnCNetManager cncnetManager; @@ -57,12 +49,9 @@ public override void Initialize() bool initUpdater = !ClientConfiguration.Instance.ModMode; if (initUpdater) - { - updaterInitTask = new Task(InitUpdater); - updaterInitTask.Start(); - } + updaterInitTask = Task.Run(InitUpdater).HandleTask(); - mapLoadTask = mapLoader.LoadMapsAsync(); + mapLoadTask = mapLoader.LoadMapsAsync().HandleTask(); if (Cursor.Visible) { @@ -85,7 +74,7 @@ private void LogGameClientVersion() private void Finish() { - ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ? + ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ? "N/A" : Updater.GameVersion; MainMenu mainMenu = serviceProvider.GetService(); @@ -120,4 +109,4 @@ public override void Update(GameTime gameTime) } } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Generic/MainMenu.cs b/DXMainClient/DXGUI/Generic/MainMenu.cs index f928ac78d..bf3fcf68c 100644 --- a/DXMainClient/DXGUI/Generic/MainMenu.cs +++ b/DXMainClient/DXGUI/Generic/MainMenu.cs @@ -20,6 +20,8 @@ using System.IO; using System.Linq; using System.Threading; +using System.Threading.Tasks; +using ClientCore.Extensions; using ClientUpdater; using DTAClient.Domain.Multiplayer; @@ -185,7 +187,7 @@ public override void Initialize() btnLan.IdleTexture = AssetLoader.LoadTexture("MainMenu/lan.png"); btnLan.HoverTexture = AssetLoader.LoadTexture("MainMenu/lan_c.png"); btnLan.HoverSoundEffect = new EnhancedSoundEffect("MainMenu/button.wav"); - btnLan.LeftClick += BtnLan_LeftClick; + btnLan.LeftClick += (_, _) => BtnLan_LeftClickAsync().HandleTask(); btnOptions = new XNAClientButton(WindowManager); btnOptions.Name = nameof(btnOptions); @@ -227,7 +229,7 @@ public override void Initialize() btnExit.IdleTexture = AssetLoader.LoadTexture("MainMenu/exitgame.png"); btnExit.HoverTexture = AssetLoader.LoadTexture("MainMenu/exitgame_c.png"); btnExit.HoverSoundEffect = new EnhancedSoundEffect("MainMenu/button.wav"); - btnExit.LeftClick += BtnExit_LeftClick; + btnExit.LeftClick += (_, _) => BtnExit_LeftClick(); XNALabel lblCnCNetStatus = new XNALabel(WindowManager); lblCnCNetStatus.Name = nameof(lblCnCNetStatus); @@ -304,7 +306,7 @@ public override void Initialize() cncnetPlayerCountCancellationSource = new CancellationTokenSource(); CnCNetPlayerCountTask.InitializeService(cncnetPlayerCountCancellationSource); - WindowManager.GameClosing += WindowManager_GameClosing; + WindowManager.GameClosing += (_, _) => CleanAsync().HandleTask(); skirmishLobby.Exited += SkirmishLobby_Exited; lanLobby.Exited += LanLobby_Exited; @@ -314,10 +316,8 @@ public override void Initialize() GameProcessLogic.GameProcessStarted += SharedUILogic_GameProcessStarted; GameProcessLogic.GameProcessStarting += SharedUILogic_GameProcessStarting; - UserINISettings.Instance.SettingsSaved += SettingsSaved; - - Updater.Restart += Updater_Restart; + Updater.Restart += (_, _) => WindowManager.AddCallback(ExitClient); SetButtonHotkeys(true); } @@ -377,16 +377,10 @@ private void SharedUILogic_GameProcessStarting() } catch (Exception ex) { - Logger.Log("Refreshing settings failed! Exception message: " + ex.Message); - // We don't want to show the dialog when starting a game - //XNAMessageBox.Show(WindowManager, "Saving settings failed", - // "Saving settings failed! Error message: " + ex.Message); + ProgramConstants.LogException(ex, "Refreshing settings failed!"); } } - private void Updater_Restart(object sender, EventArgs e) => - WindowManager.AddCallback(new Action(ExitClient), null); - /// /// Applies configuration changes (music playback and volume) /// when settings are saved. @@ -500,8 +494,6 @@ private void FirstRunMessageBox_NoClicked(XNAMessageBox messageBox) private void SharedUILogic_GameProcessStarted() => MusicOff(); - private void WindowManager_GameClosing(object sender, EventArgs e) => Clean(); - private void SkirmishLobby_Exited(object sender, EventArgs e) { if (UserINISettings.Instance.StopMusicOnMenu) @@ -531,9 +523,9 @@ private void CnCNetInfoController_CnCNetGameCountUpdated(object sender, PlayerCo } /// - /// Attemps to "clean" the client session in a nice way if the user closes the game. + /// Attempts to "clean" the client session in a nice way if the user closes the game. /// - private void Clean() + private async ValueTask CleanAsync() { Updater.FileIdentifiersUpdated -= Updater_FileIdentifiersUpdated; @@ -543,7 +535,7 @@ private void Clean() Updater.StopUpdate(); if (connectionManager.IsConnected) - connectionManager.Disconnect(); + await connectionManager.DisconnectAsync().ConfigureAwait(false); } /// @@ -605,9 +597,8 @@ public void PostInit() CheckIfFirstRun(); } - private void SwitchMainMenuMusicFormat() + private static void SwitchMainMenuMusicFormat() { -#if GL || DX FileInfo wmaMainMenuMusicFile = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.BASE_RESOURCE_PATH, FormattableString.Invariant($"{ClientConfiguration.Instance.MainMenuMusicName}.wma")); @@ -620,10 +611,9 @@ private void SwitchMainMenuMusicFormat() if (!wmaBackupMainMenuMusicFile.Exists) wmaMainMenuMusicFile.CopyTo(wmaBackupMainMenuMusicFile.FullName); -#endif -#if DX +#if !GL wmaBackupMainMenuMusicFile.CopyTo(wmaMainMenuMusicFile.FullName, true); -#elif GL +#else FileInfo oggMainMenuMusicFile = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.BASE_RESOURCE_PATH, FormattableString.Invariant($"{ClientConfiguration.Instance.MainMenuMusicName}.ogg")); @@ -645,7 +635,7 @@ private void UpdateWindow_UpdateFailed(object sender, UpdateFailureEventArgs e) innerPanel.Show(null); // Darkening XNAMessageBox msgBox = new XNAMessageBox(WindowManager, "Update failed".L10N("Client:Main:UpdateFailedTitle"), string.Format(("An error occured while updating. Returned error was: {0}\n\nIf you are connected to the Internet and your firewall isn't blocking\n{1}, and the issue is reproducible, contact us at\n{2} for support.").L10N("Client:Main:UpdateFailedText"), - e.Reason, Path.GetFileName(ProgramConstants.StartupExecutable), MainClientConstants.SUPPORT_URL_SHORT), XNAMessageBoxButtons.OK); + e.Reason, Path.GetFileName(ProgramConstants.StartupExecutable), ProgramConstants.SUPPORT_URL_SHORT), XNAMessageBoxButtons.OK); msgBox.OKClickedAction = MsgBox_OKClicked; msgBox.Show(); } @@ -668,7 +658,7 @@ private void UpdateWindow_UpdateCompleted(object sender, EventArgs e) { innerPanel.Hide(); lblUpdateStatus.Text = string.Format("{0} was succesfully updated to v.{1}".L10N("Client:Main:UpdateSuccess"), - MainClientConstants.GAME_NAME_SHORT, Updater.GameVersion); + ProgramConstants.GAME_NAME_SHORT, Updater.GameVersion); lblVersion.Text = Updater.GameVersion; UpdateInProgress = false; lblUpdateStatus.Enabled = true; @@ -718,7 +708,7 @@ private void CheckForUpdates() } private void Updater_FileIdentifiersUpdated() - => WindowManager.AddCallback(new Action(HandleFileIdentifierUpdate), null); + => WindowManager.AddCallback(HandleFileIdentifierUpdate); /// /// Used for displaying the result of an update check in the UI. @@ -732,7 +722,7 @@ private void HandleFileIdentifierUpdate() if (Updater.VersionState == VersionState.UPTODATE) { - lblUpdateStatus.Text = string.Format("{0} is up to date.".L10N("Client:Main:GameUpToDate"), MainClientConstants.GAME_NAME_SHORT); + lblUpdateStatus.Text = string.Format("{0} is up to date.".L10N("Client:Main:GameUpToDate"), ProgramConstants.GAME_NAME_SHORT); lblUpdateStatus.Enabled = true; lblUpdateStatus.DrawUnderline = false; } @@ -834,15 +824,15 @@ private void BtnNewCampaign_LeftClick(object sender, EventArgs e) private void BtnLoadGame_LeftClick(object sender, EventArgs e) => innerPanel.Show(innerPanel.GameLoadingWindow); - private void BtnLan_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLan_LeftClickAsync() { - lanLobby.Open(); + await lanLobby.OpenAsync().ConfigureAwait(false); if (UserINISettings.Instance.StopMusicOnMenu) MusicOff(); if (connectionManager.IsConnected) - connectionManager.Disconnect(); + await connectionManager.DisconnectAsync().ConfigureAwait(false); topBar.SetLanMode(true); } @@ -864,13 +854,13 @@ private void BtnStatistics_LeftClick(object sender, EventArgs e) => private void BtnCredits_LeftClick(object sender, EventArgs e) { - ProcessLauncher.StartShellProcess(MainClientConstants.CREDITS_URL); + ProcessLauncher.StartShellProcess(ProgramConstants.CREDITS_URL); } private void BtnExtras_LeftClick(object sender, EventArgs e) => innerPanel.Show(innerPanel.ExtrasWindow); - private void BtnExit_LeftClick(object sender, EventArgs e) + private void BtnExit_LeftClick() { #if WINFORMS WindowManager.HideWindow(); @@ -879,7 +869,7 @@ private void BtnExit_LeftClick(object sender, EventArgs e) } private void SharedUILogic_GameProcessExited() => - AddCallback(new Action(HandleGameProcessExited), null); + AddCallback(HandleGameProcessExited); private void HandleGameProcessExited() { @@ -941,7 +931,7 @@ private void PlayMusic() } catch (InvalidOperationException ex) { - Logger.Log("Playing main menu music failed! " + ex.Message); + ProgramConstants.LogException(ex, "Playing main menu music failed!"); } } } @@ -984,7 +974,7 @@ private void FadeMusicExit() if (MediaPlayer.Volume > step) { MediaPlayer.Volume -= step; - AddCallback(new Action(FadeMusicExit), null); + AddCallback(FadeMusicExit); } else { @@ -998,10 +988,6 @@ private void ExitClient() Logger.Log("Exiting."); WindowManager.CloseGame(); themeSong?.Dispose(); -#if !XNA - Thread.Sleep(1000); - Environment.Exit(0); -#endif } public void SwitchOn() @@ -1036,7 +1022,7 @@ private void MusicOff() } catch (Exception ex) { - Logger.Log("Turning music off failed! Message: " + ex.Message); + ProgramConstants.LogException(ex, "Turning music off failed!"); } } @@ -1052,9 +1038,9 @@ private bool IsMediaPlayerAvailable() MediaState state = MediaPlayer.State; return true; } - catch (Exception e) + catch (Exception ex) { - Logger.Log("Error encountered when checking media player availability. Error message: " + e.Message); + ProgramConstants.LogException(ex, "Error encountered when checking media player availability."); return false; } } diff --git a/DXMainClient/DXGUI/Generic/PrivacyNotification.cs b/DXMainClient/DXGUI/Generic/PrivacyNotification.cs index 1bdc4a6fe..8b3463606 100644 --- a/DXMainClient/DXGUI/Generic/PrivacyNotification.cs +++ b/DXMainClient/DXGUI/Generic/PrivacyNotification.cs @@ -1,4 +1,5 @@ -using ClientCore; +using System; +using ClientCore; using ClientGUI; using ClientCore.Extensions; using Microsoft.Xna.Framework; @@ -42,7 +43,7 @@ public override void Initialize() lblTermsAndConditions.Name = nameof(lblTermsAndConditions); lblTermsAndConditions.X = lblMoreInformation.Right + UIDesignConstants.CONTROL_HORIZONTAL_MARGIN; lblTermsAndConditions.Y = lblMoreInformation.Y; - lblTermsAndConditions.Text = "https://cncnet.org/terms-and-conditions"; + lblTermsAndConditions.Text = $"{Uri.UriSchemeHttps}://cncnet.org/terms-and-conditions"; lblTermsAndConditions.LeftClick += (s, e) => ProcessLauncher.StartShellProcess(lblTermsAndConditions.Text); AddChild(lblTermsAndConditions); @@ -50,7 +51,7 @@ public override void Initialize() lblPrivacyPolicy.Name = nameof(lblPrivacyPolicy); lblPrivacyPolicy.X = lblTermsAndConditions.Right + UIDesignConstants.CONTROL_HORIZONTAL_MARGIN; lblPrivacyPolicy.Y = lblMoreInformation.Y; - lblPrivacyPolicy.Text = "https://cncnet.org/privacy-policy"; + lblPrivacyPolicy.Text = $"{Uri.UriSchemeHttps}://cncnet.org/privacy-policy"; lblPrivacyPolicy.LeftClick += (s, e) => ProcessLauncher.StartShellProcess(lblPrivacyPolicy.Text); AddChild(lblPrivacyPolicy); diff --git a/DXMainClient/DXGUI/Generic/StatisticsWindow.cs b/DXMainClient/DXGUI/Generic/StatisticsWindow.cs index 88abe541d..5a0600ea5 100644 --- a/DXMainClient/DXGUI/Generic/StatisticsWindow.cs +++ b/DXMainClient/DXGUI/Generic/StatisticsWindow.cs @@ -11,6 +11,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Generic { @@ -154,7 +156,7 @@ public override void Initialize() chkIncludeSpectatedGames.ClientRectangle = new Rectangle( Width - chkIncludeSpectatedGames.Width - 12, cmbGameModeFilter.Bottom + 3, - chkIncludeSpectatedGames.Width, + chkIncludeSpectatedGames.Width, chkIncludeSpectatedGames.Height); chkIncludeSpectatedGames.CheckedChanged += ChkIncludeSpectatedGames_CheckedChanged; @@ -205,9 +207,9 @@ public override void Initialize() panelGameStatistics.AddChild(lbGameList); panelGameStatistics.AddChild(lbGameStatistics); -#endregion + #endregion -#region Total statistics + #region Total statistics panelTotalStatistics = new XNAPanel(WindowManager); panelTotalStatistics.Name = "panelTotalStatistics"; @@ -388,7 +390,7 @@ public override void Initialize() panelTotalStatistics.AddChild(lblFavouriteSideValue); panelTotalStatistics.AddChild(lblAverageAILevelValue); -#endregion + #endregion AddChild(tabControl); AddChild(lblFilter); @@ -413,7 +415,7 @@ public override void Initialize() mpColors = MultiplayerColor.LoadColors(); - ReadStatistics(); + ReadStatisticsAsync().HandleTask(); ListGameModes(); ListGames(); @@ -515,12 +517,12 @@ private void LbGameList_SelectedIndexChanged(object sender, EventArgs e) XNAListBoxItem spectatorItem = new XNAListBoxItem(); spectatorItem.Text = "Spectator".L10N("Client:Main:Spectator"); spectatorItem.TextColor = textColor; - spectatorItem.Texture = sideTextures[sideTextures.Length - 1]; + spectatorItem.Texture = sideTextures[^1]; items.Add(spectatorItem); items.Add(new XNAListBoxItem("-", textColor)); } else - { + { if (!ms.SawCompletion) { // The game wasn't completed - we don't know the stats @@ -579,12 +581,8 @@ private string TeamIndexToString(int teamIndex) #region Statistics reading / game listing code - private void ReadStatistics() - { - StatisticsManager sm = StatisticsManager.Instance; - - sm.ReadStatistics(ProgramConstants.GamePath); - } + private ValueTask ReadStatisticsAsync() + => StatisticsManager.Instance.ReadStatisticsAsync(ProgramConstants.GamePath); private void ListGameModes() { @@ -922,7 +920,7 @@ private void SetTotalStatistics() if (gamesStarted > 0) { - lblAverageGameLengthValue.Text = TimeSpan.FromSeconds((int)timePlayed.TotalSeconds / gamesStarted).ToString(); + lblAverageGameLengthValue.Text = TimeSpanToString(TimeSpan.FromSeconds((int)timePlayed.TotalSeconds / gamesStarted)); } else lblAverageGameLengthValue.Text = "-"; @@ -951,7 +949,7 @@ private void SetTotalStatistics() else lblKillLossRatioValue.Text = "-"; - lblTotalTimePlayedValue.Text = timePlayed.ToString(); + lblTotalTimePlayedValue.Text = TimeSpanToString(timePlayed); lblTotalKillsValue.Text = totalKills.ToString(); lblTotalLossesValue.Text = totalLosses.ToString(); lblTotalScoreValue.Text = totalScore.ToString(); @@ -965,6 +963,13 @@ private void SetTotalStatistics() lblAverageAILevelValue.Text = "Hard".L10N("Client:Main:HardAI"); } + private string TimeSpanToString(TimeSpan timeSpan) + { + return timeSpan.Days > 0 ? + $"{timeSpan.Days} d {timeSpan.Hours} h {timeSpan.Minutes} m {timeSpan.Seconds} s" : + $"{timeSpan.Hours} h {timeSpan.Minutes} m {timeSpan.Seconds} s"; + } + private PlayerStatistics FindLocalPlayer(MatchStatistics ms) { int pCount = ms.GetPlayerCount(); @@ -997,10 +1002,10 @@ private int GetHighestIndex(int[] t) return highestIndex; } - private void ClearAllStatistics() + private async ValueTask ClearAllStatisticsAsync() { - StatisticsManager.Instance.ClearDatabase(); - ReadStatistics(); + await StatisticsManager.Instance.SaveDatabaseAsync().ConfigureAwait(false); + await ReadStatisticsAsync().ConfigureAwait(false); ListGameModes(); ListGames(); } @@ -1019,12 +1024,10 @@ private void BtnClearStatistics_LeftClick(object sender, EventArgs e) var msgBox = new XNAMessageBox(WindowManager, "Clear all statistics".L10N("Client:Main:ClearStatisticsTitle"), ("All statistics data will be cleared from the database.\n\nAre you sure you want to continue?").L10N("Client:Main:ClearStatisticsText"), XNAMessageBoxButtons.YesNo); msgBox.Show(); - msgBox.YesClickedAction = ClearStatisticsConfirmation_YesClicked; + msgBox.YesClickedAction = _ => ClearStatisticsConfirmation_YesClickedAsync().HandleTask(); } - private void ClearStatisticsConfirmation_YesClicked(XNAMessageBox messageBox) - { - ClearAllStatistics(); - } + private ValueTask ClearStatisticsConfirmation_YesClickedAsync() + => ClearAllStatisticsAsync(); } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Generic/TopBar.cs b/DXMainClient/DXGUI/Generic/TopBar.cs index 639e7457b..531926141 100644 --- a/DXMainClient/DXGUI/Generic/TopBar.cs +++ b/DXMainClient/DXGUI/Generic/TopBar.cs @@ -9,6 +9,8 @@ using ClientGUI; using ClientCore; using System.Threading; +using System.Threading.Tasks; +using ClientCore.Extensions; using DTAClient.Domain.Multiplayer.CnCNet; using DTAClient.Online.EventArguments; using DTAConfig; @@ -19,7 +21,7 @@ namespace DTAClient.DXGUI.Generic /// /// A top bar that allows switching between various client windows. /// - public class TopBar : XNAPanel + internal sealed class TopBar : XNAPanel { /// /// The number of seconds that the top bar will stay down after it has @@ -92,7 +94,7 @@ public void AddPrimarySwitchable(ISwitchable switchable) public void RemovePrimarySwitchable(ISwitchable switchable) { primarySwitches.Remove(switchable); - btnMainButton.Text = primarySwitches[primarySwitches.Count - 1].GetSwitchName() + " (F2)"; + btnMainButton.Text = primarySwitches[^1].GetSwitchName() + " (F2)"; } public void SetSecondarySwitch(ISwitchable switchable) @@ -172,7 +174,7 @@ public override void Initialize() btnLogout.FontIndex = 1; btnLogout.Text = "Log Out".L10N("Client:Main:TopBarLogOut"); btnLogout.AllowClick = false; - btnLogout.LeftClick += BtnLogout_LeftClick; + btnLogout.LeftClick += (_, _) => BtnLogout_LeftClickAsync().HandleTask(); btnOptions = new XNAClientButton(WindowManager); btnOptions.Name = "btnOptions"; @@ -288,9 +290,9 @@ private void ConnectionEvent(string text) downTime = TimeSpan.FromSeconds(DOWN_TIME_WAIT_SECONDS - EVENT_DOWN_TIME_WAIT_SECONDS); } - private void BtnLogout_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLogout_LeftClickAsync() { - connectionManager.Disconnect(); + await connectionManager.DisconnectAsync().ConfigureAwait(false); LogoutEvent?.Invoke(this, null); SwitchToPrimary(); } @@ -302,7 +304,7 @@ public void SwitchToPrimary() => BtnMainButton_LeftClick(this, EventArgs.Empty); public ISwitchable GetTopMostPrimarySwitchable() - => primarySwitches[primarySwitches.Count - 1]; + => primarySwitches[^1]; public void SwitchToSecondary() => BtnCnCNetLobby_LeftClick(this, EventArgs.Empty); @@ -310,7 +312,7 @@ public void SwitchToSecondary() private void BtnCnCNetLobby_LeftClick(object sender, EventArgs e) { LastSwitchType = SwitchType.SECONDARY; - primarySwitches[primarySwitches.Count - 1].SwitchOff(); + primarySwitches[^1].SwitchOff(); cncnetLobbySwitch.SwitchOn(); privateMessageSwitch.SwitchOff(); @@ -324,11 +326,11 @@ private void BtnMainButton_LeftClick(object sender, EventArgs e) LastSwitchType = SwitchType.PRIMARY; cncnetLobbySwitch.SwitchOff(); privateMessageSwitch.SwitchOff(); - primarySwitches[primarySwitches.Count - 1].SwitchOn(); + primarySwitches[^1].SwitchOn(); // HACK warning // TODO: add a way for DarkeningPanel to skip transitions - if (((XNAControl)primarySwitches[primarySwitches.Count - 1]).Parent is DarkeningPanel darkeningPanel) + if (((XNAControl)primarySwitches[^1]).Parent is DarkeningPanel darkeningPanel) darkeningPanel.Alpha = 1.0f; } diff --git a/DXMainClient/DXGUI/Generic/UpdateWindow.cs b/DXMainClient/DXGUI/Generic/UpdateWindow.cs index 4448b43c0..122f5fc6a 100644 --- a/DXMainClient/DXGUI/Generic/UpdateWindow.cs +++ b/DXMainClient/DXGUI/Generic/UpdateWindow.cs @@ -5,6 +5,7 @@ using Rampastring.XNAUI; using Rampastring.XNAUI.XNAControls; using System; +using ClientCore; #if WINFORMS using System.Runtime.InteropServices; #endif @@ -26,15 +27,12 @@ public class UpdateWindow : XNAWindow public delegate void UpdateFailureEventHandler(object sender, UpdateFailureEventArgs e); public event UpdateFailureEventHandler UpdateFailed; - delegate void UpdateProgressChangedDelegate(string fileName, int filePercentage, int totalPercentage); - delegate void FileDownloadCompletedDelegate(string archiveName); - private const double DOT_TIME = 0.66; private const int MAX_DOTS = 5; - public UpdateWindow(WindowManager windowManager) : base(windowManager) + public UpdateWindow(WindowManager windowManager) + : base(windowManager) { - } private XNALabel lblDescription; @@ -155,13 +153,13 @@ private void Updater_FileIdentifiersUpdated() if (Updater.VersionState == VersionState.UNKNOWN) { XNAMessageBox.Show(WindowManager, "Force Update Failure".L10N("Client:Main:ForceUpdateFailureTitle"), "Checking for updates failed.".L10N("Client:Main:ForceUpdateFailureText")); - AddCallback(new Action(CloseWindow), null); + AddCallback(CloseWindow); return; } else if (Updater.VersionState == VersionState.OUTDATED && Updater.ManualUpdateRequired) { UpdateCancelled?.Invoke(this, EventArgs.Empty); - AddCallback(new Action(CloseWindow), null); + AddCallback(CloseWindow); return; } @@ -172,8 +170,7 @@ private void Updater_FileIdentifiersUpdated() private void Updater_LocalFileCheckProgressChanged(int checkedFileCount, int totalFileCount) { - AddCallback(new Action(UpdateFileProgress), - (checkedFileCount * 100 / totalFileCount)); + AddCallback(() => UpdateFileProgress(checkedFileCount * 100 / totalFileCount)); } private void UpdateFileProgress(int value) @@ -231,15 +228,16 @@ private void HandleUpdateProgressChange() tbp.SetState(WindowManager.GetWindowHandle(), TaskbarProgress.TaskbarStates.Normal); tbp.SetValue(WindowManager.GetWindowHandle(), prgTotal.Value, prgTotal.Maximum); } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); } #endif } private void Updater_OnFileDownloadCompleted(string archiveName) { - AddCallback(new FileDownloadCompletedDelegate(HandleFileDownloadCompleted), archiveName); + AddCallback(() => HandleFileDownloadCompleted(archiveName)); } private void HandleFileDownloadCompleted(string archiveName) @@ -249,7 +247,7 @@ private void HandleFileDownloadCompleted(string archiveName) private void Updater_OnUpdateCompleted() { - AddCallback(new Action(HandleUpdateCompleted), null); + AddCallback(HandleUpdateCompleted); } private void HandleUpdateCompleted() @@ -262,7 +260,7 @@ private void HandleUpdateCompleted() private void Updater_OnUpdateFailed(Exception ex) { - AddCallback(new Action(HandleUpdateFailed), ex.Message); + AddCallback(() => HandleUpdateFailed(ex.Message)); } private void HandleUpdateFailed(string updateFailureErrorMessage) @@ -293,14 +291,14 @@ private void CloseWindow() public void SetData(string newGameVersion) { - lblDescription.Text = string.Format(("Please wait while {0} is updated to version {1}.\nThis window will automatically close once the update is complete.\n\nThe client may also restart after the update has been downloaded.").L10N("Client:Main:UpdateVersionPleaseWait"), MainClientConstants.GAME_NAME_SHORT, newGameVersion); + lblDescription.Text = string.Format(("Please wait while {0} is updated to version {1}.\nThis window will automatically close once the update is complete.\n\nThe client may also restart after the update has been downloaded.").L10N("Client:Main:UpdateVersionPleaseWait"), ProgramConstants.GAME_NAME_SHORT, newGameVersion); lblUpdaterStatus.Text = "Preparing".L10N("Client:Main:StatusPreparing"); } public void ForceUpdate() { isStartingForceUpdate = true; - lblDescription.Text = string.Format("Force updating {0} to latest version...".L10N("Client:Main:ForceUpdateToLatest"), MainClientConstants.GAME_NAME_SHORT); + lblDescription.Text = string.Format("Force updating {0} to latest version...".L10N("Client:Main:ForceUpdateToLatest"), ProgramConstants.GAME_NAME_SHORT); lblUpdaterStatus.Text = "Connecting".L10N("Client:Main:UpdateStatusConnecting"); Updater.CheckForUpdates(); } diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetGameLoadingLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetGameLoadingLobby.cs index ab9a68a6f..c5544c1dc 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetGameLoadingLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetGameLoadingLobby.cs @@ -15,57 +15,47 @@ using Rampastring.XNAUI.XNAControls; using System; using System.Collections.Generic; +using System.Net; using System.Text; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Multiplayer.CnCNet { /// /// A game lobby for loading saved CnCNet games. /// - public class CnCNetGameLoadingLobby : GameLoadingLobbyBase + internal sealed class CnCNetGameLoadingLobby : GameLoadingLobbyBase { private const double GAME_BROADCAST_INTERVAL = 20.0; private const double INITIAL_GAME_BROADCAST_DELAY = 10.0; - private const string NOT_ALL_PLAYERS_PRESENT_CTCP_COMMAND = "NPRSNT"; - private const string GET_READY_CTCP_COMMAND = "GTRDY"; - private const string FILE_HASH_CTCP_COMMAND = "FHSH"; - private const string INVALID_FILE_HASH_CTCP_COMMAND = "IHSH"; - private const string TUNNEL_PING_CTCP_COMMAND = "TNLPNG"; - private const string OPTIONS_CTCP_COMMAND = "OP"; - private const string INVALID_SAVED_GAME_INDEX_CTCP_COMMAND = "ISGI"; - private const string START_GAME_CTCP_COMMAND = "START"; - private const string PLAYER_READY_CTCP_COMMAND = "READY"; - private const string CHANGE_TUNNEL_SERVER_MESSAGE = "CHTNL"; - public CnCNetGameLoadingLobby( WindowManager windowManager, TopBar topBar, CnCNetManager connectionManager, TunnelHandler tunnelHandler, - MapLoader mapLoader, GameCollection gameCollection, - DiscordHandler discordHandler - ) : base(windowManager, discordHandler) + DiscordHandler discordHandler) + : base(windowManager, discordHandler) { this.connectionManager = connectionManager; this.tunnelHandler = tunnelHandler; this.topBar = topBar; this.gameCollection = gameCollection; - this.mapLoader = mapLoader; ctcpCommandHandlers = new CommandHandlerBase[] { - new NoParamCommandHandler(NOT_ALL_PLAYERS_PRESENT_CTCP_COMMAND, HandleNotAllPresentNotification), - new NoParamCommandHandler(GET_READY_CTCP_COMMAND, HandleGetReadyNotification), - new StringCommandHandler(FILE_HASH_CTCP_COMMAND, HandleFileHashCommand), - new StringCommandHandler(INVALID_FILE_HASH_CTCP_COMMAND, HandleCheaterNotification), - new IntCommandHandler(TUNNEL_PING_CTCP_COMMAND, HandleTunnelPing), - new StringCommandHandler(OPTIONS_CTCP_COMMAND, HandleOptionsMessage), - new NoParamCommandHandler(INVALID_SAVED_GAME_INDEX_CTCP_COMMAND, HandleInvalidSaveIndexCommand), - new StringCommandHandler(START_GAME_CTCP_COMMAND, HandleStartGameCommand), - new IntCommandHandler(PLAYER_READY_CTCP_COMMAND, HandlePlayerReadyRequest), - new StringCommandHandler(CHANGE_TUNNEL_SERVER_MESSAGE, HandleTunnelServerChangeMessage) + new NoParamCommandHandler(CnCNetCommands.NOT_ALL_PLAYERS_PRESENT, sender => HandleNotAllPresentNotificationAsync(sender).HandleTask()), + new NoParamCommandHandler(CnCNetCommands.GET_READY, sender => HandleGetReadyNotificationAsync(sender).HandleTask()), + new StringCommandHandler(CnCNetCommands.FILE_HASH, (sender, fileHash) => HandleFileHashCommandAsync(sender, fileHash).HandleTask()), + new StringCommandHandler(CnCNetCommands.INVALID_FILE_HASH, (sender, cheaterName) => HandleCheaterNotificationAsync(sender, cheaterName).HandleTask()), + new IntCommandHandler(CnCNetCommands.TUNNEL_PING, HandleTunnelPing), + new StringCommandHandler(CnCNetCommands.OPTIONS, (sender, data) => HandleOptionsMessageAsync(sender, data).HandleTask()), + new NoParamCommandHandler(CnCNetCommands.INVALID_SAVED_GAME_INDEX, HandleInvalidSaveIndexCommand), + new StringCommandHandler(CnCNetCommands.START_GAME, (sender, data) => HandleStartGameCommandAsync(sender, data).HandleTask()), + new IntCommandHandler(CnCNetCommands.PLAYER_READY, (sender, readyStatus) => HandlePlayerReadyRequestAsync(sender, readyStatus).HandleTask()), + new StringCommandHandler(CnCNetCommands.CHANGE_TUNNEL_SERVER, HandleTunnelServerChangeMessage) }; } @@ -76,7 +66,6 @@ DiscordHandler discordHandler private List gameModes; private TunnelHandler tunnelHandler; - private readonly MapLoader mapLoader; private TunnelSelectionWindow tunnelSelectionWindow; private XNAClientButton btnChangeTunnel; @@ -100,21 +89,19 @@ DiscordHandler discordHandler private TopBar topBar; + private EventHandler channel_UserLeftFunc; + private EventHandler channel_UserQuitIRCFunc; + private EventHandler channel_UserAddedFunc; + public override void Initialize() { dp = new DarkeningPanel(WindowManager); - //WindowManager.AddAndInitializeControl(dp); - - //dp.AddChildWithoutInitialize(this); - - //dp.Alpha = 0.0f; - //dp.Hide(); localGame = ClientConfiguration.Instance.LocalGame; base.Initialize(); - connectionManager.ConnectionLost += ConnectionManager_ConnectionLost; - connectionManager.Disconnected += ConnectionManager_Disconnected; + connectionManager.ConnectionLost += (_, _) => ClearAsync().HandleTask(); + connectionManager.Disconnected += (_, _) => ClearAsync().HandleTask(); tunnelSelectionWindow = new TunnelSelectionWindow(WindowManager, tunnelHandler); tunnelSelectionWindow.Initialize(); @@ -123,7 +110,7 @@ public override void Initialize() DarkeningPanel.AddAndInitializeWithControl(WindowManager, tunnelSelectionWindow); tunnelSelectionWindow.CenterOnParent(); tunnelSelectionWindow.Disable(); - tunnelSelectionWindow.TunnelSelected += TunnelSelectionWindow_TunnelSelected; + tunnelSelectionWindow.TunnelSelected += (_, e) => TunnelSelectionWindow_TunnelSelectedAsync(e).HandleTask(); btnChangeTunnel = new XNAClientButton(WindowManager); btnChangeTunnel.Name = nameof(btnChangeTunnel); @@ -137,32 +124,29 @@ public override void Initialize() gameBroadcastTimer.AutoReset = true; gameBroadcastTimer.Interval = TimeSpan.FromSeconds(GAME_BROADCAST_INTERVAL); gameBroadcastTimer.Enabled = true; - gameBroadcastTimer.TimeElapsed += GameBroadcastTimer_TimeElapsed; + gameBroadcastTimer.TimeElapsed += (_, _) => BroadcastGameAsync().HandleTask(); WindowManager.AddAndInitializeControl(gameBroadcastTimer); } private void BtnChangeTunnel_LeftClick(object sender, EventArgs e) => ShowTunnelSelectionWindow("Select tunnel server:"); - private void GameBroadcastTimer_TimeElapsed(object sender, EventArgs e) => BroadcastGame(); - - private void ConnectionManager_Disconnected(object sender, EventArgs e) => Clear(); - - private void ConnectionManager_ConnectionLost(object sender, ConnectionLostEventArgs e) => Clear(); - /// /// Sets up events and information before joining the channel. /// - public void SetUp(bool isHost, CnCNetTunnel tunnel, Channel channel, - string hostName) + public void SetUp(bool isHost, CnCNetTunnel tunnel, Channel channel, string hostName) { this.channel = channel; this.hostName = hostName; + channel_UserLeftFunc = (_, args) => OnPlayerLeftAsync(args).HandleTask(); + channel_UserQuitIRCFunc = (_, args) => OnPlayerLeftAsync(args).HandleTask(); + channel_UserAddedFunc = (_, args) => Channel_UserAddedAsync(args).HandleTask(); + channel.MessageAdded += Channel_MessageAdded; - channel.UserAdded += Channel_UserAdded; - channel.UserLeft += Channel_UserLeft; - channel.UserQuitIRC += Channel_UserQuitIRC; + channel.UserAdded += channel_UserAddedFunc; + channel.UserLeft += channel_UserLeftFunc; + channel.UserQuitIRC += channel_UserQuitIRCFunc; channel.CTCPReceived += Channel_CTCPReceived; tunnelHandler.CurrentTunnel = tunnel; @@ -181,19 +165,19 @@ private void TunnelHandler_CurrentTunnelPinged(object sender, EventArgs e) /// /// Clears event subscriptions and leaves the channel. /// - public void Clear() + public async ValueTask ClearAsync() { gameBroadcastTimer.Enabled = false; if (channel != null) { // TODO leave channel only if we've joined the channel - channel.Leave(); + await channel.LeaveAsync().ConfigureAwait(false); channel.MessageAdded -= Channel_MessageAdded; - channel.UserAdded -= Channel_UserAdded; - channel.UserLeft -= Channel_UserLeft; - channel.UserQuitIRC -= Channel_UserQuitIRC; + channel.UserAdded -= channel_UserAddedFunc; + channel.UserLeft -= channel_UserLeftFunc; + channel.UserQuitIRC -= channel_UserQuitIRCFunc; channel.CTCPReceived -= Channel_CTCPReceived; connectionManager.RemoveChannel(channel); @@ -204,7 +188,7 @@ public void Clear() Enabled = false; Visible = false; - base.LeaveGame(); + await base.LeaveGameAsync().ConfigureAwait(false); } tunnelHandler.CurrentTunnel = null; @@ -227,22 +211,21 @@ private void Channel_CTCPReceived(object sender, ChannelCTCPEventArgs e) /// /// Called when the local user has joined the game channel. /// - public void OnJoined() + public async ValueTask OnJoinedAsync() { FileHashCalculator fhc = new FileHashCalculator(); fhc.CalculateHashes(gameModes); if (IsHost) { - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("MODE {0} +klnNs {1} {2}", channel.ChannelName, - channel.Password, SGPlayers.Count), - QueuedMessageType.SYSTEM_MESSAGE, 50)); + await connectionManager.SendCustomMessageAsync(new QueuedMessage( + FormattableString.Invariant($"{IRCCommands.MODE} {channel.ChannelName} +{IRCChannelModes.DEFAULT} {channel.Password} {SGPlayers.Count}"), + QueuedMessageType.SYSTEM_MESSAGE, 50)).ConfigureAwait(false); - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("TOPIC {0} :{1}", channel.ChannelName, + await connectionManager.SendCustomMessageAsync(new QueuedMessage( + string.Format(IRCCommands.TOPIC + " {0} :{1}", channel.ChannelName, ProgramConstants.CNCNET_PROTOCOL_REVISION + ";" + localGame.ToLower()), - QueuedMessageType.SYSTEM_MESSAGE, 50)); + QueuedMessageType.SYSTEM_MESSAGE, 50)).ConfigureAwait(false); gameFilesHash = fhc.GetCompleteHash(); @@ -252,9 +235,10 @@ public void OnJoined() } else { - channel.SendCTCPMessage(FILE_HASH_CTCP_COMMAND + " " + fhc.GetCompleteHash(), QueuedMessageType.SYSTEM_MESSAGE, 10); - - channel.SendCTCPMessage(TUNNEL_PING_CTCP_COMMAND + " " + tunnelHandler.CurrentTunnel.PingInMs, QueuedMessageType.SYSTEM_MESSAGE, 10); + await channel.SendCTCPMessageAsync( + CnCNetCommands.FILE_HASH + " " + fhc.GetCompleteHash(), QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); + await channel.SendCTCPMessageAsync( + CnCNetCommands.TUNNEL_PING + " " + tunnelHandler.CurrentTunnel.PingInMs, QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); if (tunnelHandler.CurrentTunnel.PingInMs < 0) AddNotice(string.Format("{0} - unknown ping to tunnel server.".L10N("Client:Main:PlayerUnknownPing"), ProgramConstants.PLAYERNAME)); @@ -268,7 +252,7 @@ public void OnJoined() UpdateDiscordPresence(true); } - private void Channel_UserAdded(object sender, ChannelUserEventArgs e) + private async ValueTask Channel_UserAddedAsync(ChannelUserEventArgs e) { PlayerInfo pInfo = new PlayerInfo(); pInfo.Name = e.User.IRCUser.Name; @@ -277,24 +261,18 @@ private void Channel_UserAdded(object sender, ChannelUserEventArgs e) sndJoinSound.Play(); - BroadcastOptions(); + await BroadcastOptionsAsync().ConfigureAwait(false); CopyPlayerDataToUI(); UpdateDiscordPresence(); } - private void Channel_UserLeft(object sender, UserNameEventArgs e) - { - RemovePlayer(e.UserName); - UpdateDiscordPresence(); - } - - private void Channel_UserQuitIRC(object sender, UserNameEventArgs e) + private async ValueTask OnPlayerLeftAsync(UserNameEventArgs e) { - RemovePlayer(e.UserName); + await RemovePlayerAsync(e.UserName).ConfigureAwait(false); UpdateDiscordPresence(); } - private void RemovePlayer(string playerName) + private async ValueTask RemovePlayerAsync(string playerName) { int index = Players.FindIndex(p => p.Name == playerName); @@ -312,7 +290,7 @@ private void RemovePlayer(string playerName) connectionManager.MainChannel.AddMessage(new ChatMessage( Color.Yellow, "The game host left the game!".L10N("Client:Main:HostLeft"))); - Clear(); + await ClearAsync().ConfigureAwait(false); } } @@ -326,7 +304,7 @@ private void Channel_MessageAdded(object sender, IRCMessageEventArgs e) protected override void AddNotice(string message, Color color) => channel.AddMessage(new ChatMessage(color, message)); - protected override void BroadcastOptions() + protected override async ValueTask BroadcastOptionsAsync() { if (!IsHost) return; @@ -334,7 +312,7 @@ protected override void BroadcastOptions() //if (Players.Count > 0) Players[0].Ready = true; - StringBuilder message = new StringBuilder(OPTIONS_CTCP_COMMAND + " "); + StringBuilder message = new StringBuilder(CnCNetCommands.OPTIONS + " "); message.Append(ddSavedGame.SelectedIndex); message.Append(";"); foreach (PlayerInfo pInfo in Players) @@ -346,72 +324,71 @@ protected override void BroadcastOptions() } message.Remove(message.Length - 1, 1); - channel.SendCTCPMessage(message.ToString(), QueuedMessageType.GAME_SETTINGS_MESSAGE, 10); + await channel.SendCTCPMessageAsync(message.ToString(), QueuedMessageType.GAME_SETTINGS_MESSAGE, 10).ConfigureAwait(false); } - protected override void SendChatMessage(string message) + protected override ValueTask SendChatMessageAsync(string message) { sndMessageSound.Play(); - channel.SendChatMessage(message, chatColor); + return channel.SendChatMessageAsync(message, chatColor); } - protected override void RequestReadyStatus() => - channel.SendCTCPMessage(PLAYER_READY_CTCP_COMMAND + " 1", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 10); + protected override ValueTask RequestReadyStatusAsync() => + channel.SendCTCPMessageAsync(CnCNetCommands.PLAYER_READY + " 1", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 10); - protected override void GetReadyNotification() + protected override async ValueTask GetReadyNotificationAsync() { - base.GetReadyNotification(); + await base.GetReadyNotificationAsync().ConfigureAwait(false); topBar.SwitchToPrimary(); if (IsHost) - channel.SendCTCPMessage(GET_READY_CTCP_COMMAND, QueuedMessageType.GAME_GET_READY_MESSAGE, 0); + await channel.SendCTCPMessageAsync(CnCNetCommands.GET_READY, QueuedMessageType.GAME_GET_READY_MESSAGE, 0).ConfigureAwait(false); } - protected override void NotAllPresentNotification() + protected override async ValueTask NotAllPresentNotificationAsync() { - base.NotAllPresentNotification(); + await base.NotAllPresentNotificationAsync().ConfigureAwait(false); if (IsHost) { - channel.SendCTCPMessage(NOT_ALL_PLAYERS_PRESENT_CTCP_COMMAND, - QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + await channel.SendCTCPMessageAsync(CnCNetCommands.NOT_ALL_PLAYERS_PRESENT, + QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); } } private void ShowTunnelSelectionWindow(string description) - { - tunnelSelectionWindow.Open(description, - tunnelHandler.CurrentTunnel?.Address); - } + => tunnelSelectionWindow.Open(description, tunnelHandler.CurrentTunnel); - private void TunnelSelectionWindow_TunnelSelected(object sender, TunnelEventArgs e) + private async ValueTask TunnelSelectionWindow_TunnelSelectedAsync(TunnelEventArgs e) { - channel.SendCTCPMessage($"{CHANGE_TUNNEL_SERVER_MESSAGE} {e.Tunnel.Address}:{e.Tunnel.Port}", - QueuedMessageType.SYSTEM_MESSAGE, 10); + await channel.SendCTCPMessageAsync( + $"{CnCNetCommands.CHANGE_TUNNEL_SERVER} {e.Tunnel.Hash}", + QueuedMessageType.SYSTEM_MESSAGE, + 10).ConfigureAwait(false); HandleTunnelServerChange(e.Tunnel); } #region CTCP Handlers - private void HandleGetReadyNotification(string sender) + private async ValueTask HandleGetReadyNotificationAsync(string sender) { if (sender != hostName) return; - GetReadyNotification(); + await GetReadyNotificationAsync().ConfigureAwait(false); } - private void HandleNotAllPresentNotification(string sender) + private async ValueTask HandleNotAllPresentNotificationAsync(string sender) { if (sender != hostName) return; - NotAllPresentNotification(); + await NotAllPresentNotificationAsync().ConfigureAwait(false); } - private void HandleFileHashCommand(string sender, string fileHash) + private async ValueTask HandleFileHashCommandAsync(string sender, string fileHash) { if (!IsHost) return; @@ -425,11 +402,11 @@ private void HandleFileHashCommand(string sender, string fileHash) pInfo.Verified = true; - HandleCheaterNotification(hostName, sender); // This is kinda hacky + await HandleCheaterNotificationAsync(hostName, sender).ConfigureAwait(false); // This is kinda hacky } } - private void HandleCheaterNotification(string sender, string cheaterName) + private async ValueTask HandleCheaterNotificationAsync(string sender, string cheaterName) { if (sender != hostName) return; @@ -437,7 +414,7 @@ private void HandleCheaterNotification(string sender, string cheaterName) AddNotice(string.Format("{0} - modified files detected! They could be cheating!".L10N("Client:Main:PlayerCheating"), cheaterName), Color.Red); if (IsHost) - channel.SendCTCPMessage(INVALID_FILE_HASH_CTCP_COMMAND + " " + cheaterName, QueuedMessageType.SYSTEM_MESSAGE, 0); + await channel.SendCTCPMessageAsync(CnCNetCommands.INVALID_FILE_HASH + " " + cheaterName, QueuedMessageType.SYSTEM_MESSAGE, 0).ConfigureAwait(false); } private void HandleTunnelPing(string sender, int pingInMs) @@ -451,7 +428,7 @@ private void HandleTunnelPing(string sender, int pingInMs) /// /// Handles an options broadcast sent by the game host. /// - private void HandleOptionsMessage(string sender, string data) + private async ValueTask HandleOptionsMessageAsync(string sender, string data) { if (sender != hostName) return; @@ -469,7 +446,7 @@ private void HandleOptionsMessage(string sender, string data) if (sgIndex >= ddSavedGame.Items.Count) { AddNotice("The game host has selected an invalid saved game index!".L10N("Client:Main:HostInvalidIndex") + " " + sgIndex); - channel.SendCTCPMessage(INVALID_SAVED_GAME_INDEX_CTCP_COMMAND, QueuedMessageType.SYSTEM_MESSAGE, 10); + await channel.SendCTCPMessageAsync(CnCNetCommands.INVALID_SAVED_GAME_INDEX, QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); return; } @@ -513,7 +490,7 @@ private void HandleInvalidSaveIndexCommand(string sender) CopyPlayerDataToUI(); } - private void HandleStartGameCommand(string sender, string data) + private async ValueTask HandleStartGameCommandAsync(string sender, string data) { if (sender != hostName) return; @@ -547,10 +524,10 @@ private void HandleStartGameCommand(string sender, string data) pInfo.Port = port; } - LoadGame(); + await LoadGameAsync().ConfigureAwait(false); } - private void HandlePlayerReadyRequest(string sender, int readyStatus) + private async ValueTask HandlePlayerReadyRequestAsync(string sender, int readyStatus) { PlayerInfo pInfo = Players.Find(p => p.Name == sender); @@ -562,19 +539,16 @@ private void HandlePlayerReadyRequest(string sender, int readyStatus) CopyPlayerDataToUI(); if (IsHost) - BroadcastOptions(); + await BroadcastOptionsAsync().ConfigureAwait(false); } - private void HandleTunnelServerChangeMessage(string sender, string tunnelAddressAndPort) + private void HandleTunnelServerChangeMessage(string sender, string hash) { if (sender != hostName) return; - string[] split = tunnelAddressAndPort.Split(':'); - string tunnelAddress = split[0]; - int tunnelPort = int.Parse(split[1]); + CnCNetTunnel tunnel = tunnelHandler.Tunnels.Find(t => t.Hash.Equals(hash, StringComparison.OrdinalIgnoreCase)); - CnCNetTunnel tunnel = tunnelHandler.Tunnels.Find(t => t.Address == tunnelAddress && t.Port == tunnelPort); if (tunnel == null) { AddNotice(("The game host has selected an invalid tunnel server! " + @@ -597,42 +571,43 @@ private void HandleTunnelServerChange(CnCNetTunnel tunnel) { tunnelHandler.CurrentTunnel = tunnel; AddNotice(string.Format("The game host has changed the tunnel server to: {0}".L10N("Client:Main:HostChangeTunnel"), tunnel.Name)); - //UpdatePing(); } #endregion - protected override void HostStartGame() + protected override async ValueTask HostStartGameAsync() { AddNotice("Contacting tunnel server...".L10N("Client:Main:ConnectingTunnel")); - List playerPorts = tunnelHandler.CurrentTunnel.GetPlayerPortInfo(SGPlayers.Count); + List playerPorts = await tunnelHandler.CurrentTunnel.GetPlayerPortInfoAsync(SGPlayers.Count).ConfigureAwait(false); if (playerPorts.Count < Players.Count) { - ShowTunnelSelectionWindow(("An error occured while contacting the CnCNet tunnel server.\nTry picking a different tunnel server:").L10N("Client:Main:ConnectTunnelError1")); + ShowTunnelSelectionWindow(("An error occured while contacting " + + "the CnCNet tunnel server." + Environment.NewLine + + "Try picking a different tunnel server:").L10N("Client:Main:ConnectTunnelError1")); AddNotice(("An error occured while contacting the specified CnCNet " + - "tunnel server. Please try using a different tunnel server").L10N("Client:Main:ConnectTunnelError2") + " ", Color.Yellow); + "tunnel server. Please try using a different tunnel server ").L10N("Client:Main:ConnectTunnelError2"), Color.Yellow); return; } - StringBuilder sb = new StringBuilder(START_GAME_CTCP_COMMAND + " "); + StringBuilder sb = new StringBuilder(CnCNetCommands.START_GAME + " "); for (int pId = 0; pId < Players.Count; pId++) { Players[pId].Port = playerPorts[pId]; sb.Append(Players[pId].Name); sb.Append(";"); - sb.Append("0.0.0.0:"); + sb.Append($"{IPAddress.Any}:"); sb.Append(playerPorts[pId]); sb.Append(";"); } sb.Remove(sb.Length - 1, 1); - channel.SendCTCPMessage(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 9); + await channel.SendCTCPMessageAsync(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); AddNotice("Starting game...".L10N("Client:Main:StartingGame")); started = true; - LoadGame(); + await LoadGameAsync().ConfigureAwait(false); } protected override void WriteSpawnIniAdditions(IniFile spawnIni) @@ -643,14 +618,13 @@ protected override void WriteSpawnIniAdditions(IniFile spawnIni) base.WriteSpawnIniAdditions(spawnIni); } - protected override void HandleGameProcessExited() + protected override async ValueTask HandleGameProcessExitedAsync() { - base.HandleGameProcessExited(); - - Clear(); + await base.HandleGameProcessExitedAsync().ConfigureAwait(false); + await ClearAsync().ConfigureAwait(false); } - protected override void LeaveGame() => Clear(); + protected override ValueTask LeaveGameAsync() => ClearAsync(); public void ChangeChatColor(IRCColor chatColor) { @@ -658,14 +632,14 @@ public void ChangeChatColor(IRCColor chatColor) tbChatInput.TextColor = chatColor.XnaColor; } - private void BroadcastGame() + private async ValueTask BroadcastGameAsync() { Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); if (broadcastChannel == null) return; - StringBuilder sb = new StringBuilder("GAME "); + StringBuilder sb = new StringBuilder(CnCNetCommands.GAME + " "); sb.Append(ProgramConstants.CNCNET_PROTOCOL_REVISION); sb.Append(";"); sb.Append(ProgramConstants.GAME_VERSION); @@ -697,11 +671,11 @@ private void BroadcastGame() sb.Append(";"); sb.Append((string)lblGameModeValue.Tag); sb.Append(";"); - sb.Append(tunnelHandler.CurrentTunnel.Address + ":" + tunnelHandler.CurrentTunnel.Port); + sb.Append(tunnelHandler.CurrentTunnel?.Hash ?? ProgramConstants.CNCNET_DYNAMIC_TUNNELS); sb.Append(";"); sb.Append(0); // LoadedGameId - broadcastChannel.SendCTCPMessage(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 20); + await broadcastChannel.SendCTCPMessageAsync(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 20).ConfigureAwait(false); } public override string GetSwitchName() => "Load Game".L10N("Client:Main:LoadGame"); @@ -722,4 +696,4 @@ protected override void UpdateDiscordPresence(bool resetTimer = false) channel.UIName, IsHost, resetTimer); } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 0e5448bb0..e7fc2b3e1 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -14,10 +14,12 @@ using Rampastring.XNAUI.XNAControls; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using ClientCore.Enums; using DTAConfig; using ClientCore.Extensions; @@ -30,15 +32,14 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet using UserChannelPair = Tuple; using InvitationIndex = Dictionary, WeakReference>; - internal class CnCNetLobby : XNAWindow, ISwitchable + internal sealed class CnCNetLobby : XNAWindow, ISwitchable { public event EventHandler UpdateCheck; public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, CnCNetGameLobby gameLobby, CnCNetGameLoadingLobby gameLoadingLobby, TopBar topBar, PrivateMessagingWindow pmWindow, TunnelHandler tunnelHandler, - GameCollection gameCollection, CnCNetUserData cncnetUserData, - OptionsWindow optionsWindow, MapLoader mapLoader) + GameCollection gameCollection, CnCNetUserData cncnetUserData, MapLoader mapLoader) : base(windowManager) { this.connectionManager = connectionManager; @@ -49,23 +50,20 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, this.pmWindow = pmWindow; this.gameCollection = gameCollection; this.cncnetUserData = cncnetUserData; - this.optionsWindow = optionsWindow; this.mapLoader = mapLoader; ctcpCommandHandlers = new CommandHandlerBase[] { - new StringCommandHandler(ProgramConstants.GAME_INVITE_CTCP_COMMAND, HandleGameInviteCommand), - new NoParamCommandHandler(ProgramConstants.GAME_INVITATION_FAILED_CTCP_COMMAND, HandleGameInvitationFailedNotification) + new StringCommandHandler(CnCNetCommands.GAME_INVITE, (sender, argumentsString) => HandleGameInviteCommandAsync(sender, argumentsString).HandleTask()), + new NoParamCommandHandler(CnCNetCommands.GAME_INVITATION_FAILED, HandleGameInvitationFailedNotification) }; topBar.LogoutEvent += LogoutEvent; } - private MapLoader mapLoader; - - private CnCNetManager connectionManager; - private CnCNetUserData cncnetUserData; - private readonly OptionsWindow optionsWindow; + private readonly MapLoader mapLoader; + private readonly CnCNetManager connectionManager; + private readonly CnCNetUserData cncnetUserData; private PlayerListBox lbPlayerList; private ChatListBox lbChatMessages; @@ -96,50 +94,53 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private Channel currentChatChannel; - private GameCollection gameCollection; - - private Color cAdminNameColor; + private readonly GameCollection gameCollection; private Texture2D unknownGameIcon; - private Texture2D adminGameIcon; private EnhancedSoundEffect sndGameCreated; private EnhancedSoundEffect sndGameInviteReceived; - private IRCColor[] chatColors; + private readonly CnCNetGameLobby gameLobby; + private readonly CnCNetGameLoadingLobby gameLoadingLobby; - private CnCNetGameLobby gameLobby; - private CnCNetGameLoadingLobby gameLoadingLobby; - - private TunnelHandler tunnelHandler; + private readonly TunnelHandler tunnelHandler; private CnCNetLoginWindow loginWindow; - private TopBar topBar; + private readonly TopBar topBar; - private PrivateMessagingWindow pmWindow; + private readonly PrivateMessagingWindow pmWindow; private PasswordRequestWindow passwordRequestWindow; - private bool isInGameRoom = false; - private bool updateDenied = false; + private bool isInGameRoom; + private bool updateDenied; private string localGameID; private CnCNetGame localGame; - private List followedGames = new List(); + private readonly List followedGames = new List(); - private bool isJoiningGame = false; + private bool isJoiningGame; private HostedCnCNetGame gameOfLastJoinAttempt; private CancellationTokenSource gameCheckCancellation; - private CommandHandlerBase[] ctcpCommandHandlers; + private readonly CommandHandlerBase[] ctcpCommandHandlers; private InvitationIndex invitationIndex; private GameFiltersPanel panelGameFilters; + private EventHandler gameChannel_UserAddedFunc; + private EventHandler gameChannel_InvalidPasswordEntered_LoadedGameFunc; + private EventHandler gameLoadingChannel_UserAddedFunc; + private EventHandler gameChannel_InvalidPasswordEntered_NewGameFunc; + private EventHandler gameChannel_InviteOnlyErrorOnJoinFunc; + private EventHandler gameChannel_ChannelFullFunc; + private EventHandler gameChannel_TargetChangeTooFastFunc; + private void GameList_ClientRectangleUpdated(object sender, EventArgs e) { panelGameFilters.ClientRectangle = lbGameList.ClientRectangle; @@ -175,19 +176,18 @@ public override void Initialize() btnNewGame.Y, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnJoinGame.Text = "Join Game".L10N("Client:Main:JoinGame"); btnJoinGame.AllowClick = false; - btnJoinGame.LeftClick += BtnJoinGame_LeftClick; + btnJoinGame.LeftClick += (_, _) => JoinSelectedGameAsync().HandleTask(); btnLogout = new XNAClientButton(WindowManager); btnLogout.Name = nameof(btnLogout); btnLogout.ClientRectangle = new Rectangle(Width - 145, btnNewGame.Y, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); - btnLogout.Text = "Log Out".L10N("Client:Main:LogOut"); - btnLogout.LeftClick += BtnLogout_LeftClick; + btnLogout.Text = "Log Out".L10N("Client:Main:ButtonLogOut"); + btnLogout.LeftClick += (_, _) => BtnLogout_LeftClickAsync().HandleTask(); var gameListRectangle = new Rectangle( btnNewGame.X, 41, - btnJoinGame.Right - btnNewGame.X, btnNewGame.Y - 47 - ); + btnJoinGame.Right - btnNewGame.X, btnNewGame.Y - 47); panelGameFilters = new GameFiltersPanel(WindowManager); panelGameFilters.Name = nameof(panelGameFilters); @@ -199,7 +199,7 @@ public override void Initialize() lbGameList.ClientRectangle = gameListRectangle; lbGameList.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; lbGameList.BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 128), 1, 1); - lbGameList.DoubleLeftClick += LbGameList_DoubleLeftClick; + lbGameList.DoubleLeftClick += (_, _) => JoinSelectedGameAsync().HandleTask(); lbGameList.AllowMultiLineItems = false; lbGameList.ClientRectangleUpdated += GameList_ClientRectangleUpdated; @@ -215,7 +215,7 @@ public override void Initialize() lbPlayerList.RightClick += LbPlayerList_RightClick; globalContextMenu = new GlobalContextMenu(WindowManager, connectionManager, cncnetUserData, pmWindow); - globalContextMenu.JoinEvent += (sender, args) => JoinUser(args.IrcUser, connectionManager.MainChannel); + globalContextMenu.JoinEvent += (_, args) => JoinUserAsync(args.IrcUser, connectionManager.MainChannel).HandleTask(); lbChatMessages = new ChatListBox(WindowManager); lbChatMessages.Name = nameof(lbChatMessages); @@ -224,7 +224,7 @@ public override void Initialize() lbChatMessages.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; lbChatMessages.BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 128), 1, 1); lbChatMessages.LineHeight = 16; - lbChatMessages.LeftClick += (sender, args) => lbGameList.SelectedIndex = -1; + lbChatMessages.LeftClick += (_, _) => lbGameList.SelectedIndex = -1; lbChatMessages.RightClick += LbChatMessages_RightClick; tbChatInput = new XNAChatTextBox(WindowManager); @@ -235,7 +235,7 @@ public override void Initialize() tbChatInput.Suggestion = "Type here to chat...".L10N("Client:Main:ChatHere"); tbChatInput.Enabled = false; tbChatInput.MaximumTextLength = 200; - tbChatInput.EnterPressed += TbChatInput_EnterPressed; + tbChatInput.EnterPressed += (_, _) => TbChatInput_EnterPressedAsync().HandleTask(); lblColor = new XNALabel(WindowManager); lblColor.Name = nameof(lblColor); @@ -248,8 +248,6 @@ public override void Initialize() ddColor.ClientRectangle = new Rectangle(lblColor.X + 95, 12, 150, 21); - chatColors = connectionManager.GetIRCColors(); - foreach (IRCColor color in connectionManager.GetIRCColors()) { if (!color.Selectable) @@ -276,7 +274,7 @@ public override void Initialize() ddCurrentChannel.ClientRectangle = new Rectangle( lbChatMessages.Right - 200, ddColor.Y, 200, 21); - ddCurrentChannel.SelectedIndexChanged += DdCurrentChannel_SelectedIndexChanged; + ddCurrentChannel.SelectedIndexChanged += (_, _) => DdCurrentChannel_SelectedIndexChangedAsync().HandleTask(); ddCurrentChannel.AllowDropDown = false; lblCurrentChannel = new XNALabel(WindowManager); @@ -302,8 +300,7 @@ public override void Initialize() tbGameSearch = new XNASuggestionTextBox(WindowManager); tbGameSearch.Name = nameof(tbGameSearch); - tbGameSearch.ClientRectangle = new Rectangle(lbGameList.X, - 12, lbGameList.Width - 62, 21); + tbGameSearch.ClientRectangle = new Rectangle(lbGameList.X, 12, lbGameList.Width - 62, 21); tbGameSearch.Suggestion = "Filter by name, map, game mode, player...".L10N("Client:Main:FilterByBlahBlah"); tbGameSearch.MaximumTextLength = 64; tbGameSearch.InputReceived += TbGameSearch_InputReceived; @@ -355,13 +352,19 @@ public override void Initialize() AddChild(btnGameSortAlpha); AddChild(btnGameFilterOptions); - panelGameFilters.VisibleChanged += GameFiltersPanel_VisibleChanged; CnCNetPlayerCountTask.CnCNetGameCountUpdated += OnCnCNetGameCountUpdated; - UpdateOnlineCount(CnCNetPlayerCountTask.PlayerCount); - pmWindow.SetJoinUserAction(JoinUser); + gameChannel_UserAddedFunc = (sender, e) => GameChannel_UserAddedAsync(sender, e).HandleTask(); + gameChannel_InvalidPasswordEntered_LoadedGameFunc = (sender, _) => GameChannel_InvalidPasswordEntered_LoadedGameAsync(sender).HandleTask(); + gameLoadingChannel_UserAddedFunc = (sender, e) => GameLoadingChannel_UserAddedAsync(sender, e).HandleTask(); + gameChannel_InvalidPasswordEntered_NewGameFunc = (sender, _) => GameChannel_InvalidPasswordEntered_NewGameAsync(sender).HandleTask(); + gameChannel_InviteOnlyErrorOnJoinFunc = (sender, _) => OnGameLocked(sender).HandleTask(); + gameChannel_ChannelFullFunc = (sender, _) => OnGameLocked(sender).HandleTask(); + gameChannel_TargetChangeTooFastFunc = (sender, e) => GameChannel_TargetChangeTooFastAsync(sender, e).HandleTask(); + + pmWindow.SetJoinUserAction((user, messageView) => JoinUserAsync(user, messageView).HandleTask()); base.Initialize(); @@ -527,16 +530,12 @@ private void PostUIInit() sndGameCreated = new EnhancedSoundEffect("gamecreated.wav"); sndGameInviteReceived = new EnhancedSoundEffect("pm.wav"); - cAdminNameColor = AssetLoader.GetColorFromString(ClientConfiguration.Instance.AdminNameColor); - var assembly = Assembly.GetAssembly(typeof(GameCollection)); using Stream unknownIconStream = assembly.GetManifestResourceStream("ClientCore.Resources.unknownicon.png"); - using Stream cncnetIconStream = assembly.GetManifestResourceStream("ClientCore.Resources.cncneticon.png"); unknownGameIcon = AssetLoader.TextureFromImage(Image.Load(unknownIconStream)); - adminGameIcon = AssetLoader.TextureFromImage(Image.Load(cncnetIconStream)); - connectionManager.WelcomeMessageReceived += ConnectionManager_WelcomeMessageReceived; + connectionManager.WelcomeMessageReceived += (_, _) => ConnectionManager_WelcomeMessageReceivedAsync().HandleTask(); connectionManager.Disconnected += ConnectionManager_Disconnected; connectionManager.PrivateCTCPReceived += ConnectionManager_PrivateCTCPReceived; @@ -550,16 +549,16 @@ private void PostUIInit() gameCreationPanel.AddChild(gcw); gameCreationPanel.Tag = gcw; gcw.Cancelled += Gcw_Cancelled; - gcw.GameCreated += Gcw_GameCreated; - gcw.LoadedGameCreated += Gcw_LoadedGameCreated; + gcw.GameCreated += (_, e) => Gcw_GameCreatedAsync(e).HandleTask(); + gcw.LoadedGameCreated += (_, e) => Gcw_LoadedGameCreatedAsync(e).HandleTask(); gameCreationPanel.Hide(); connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, Renderer.GetSafeString( - string.Format("*** DTA CnCNet Client version {0} ***".L10N("Client:Main:CnCNetClientVersionMessage"), Assembly.GetAssembly(typeof(CnCNetLobby)).GetName().Version), + string.Format("*** CnCNet Client version {0} ***".L10N("Client:Main:CnCNetClientVersionMessage"), Assembly.GetAssembly(typeof(CnCNetLobby)).GetName().Version), lbChatMessages.FontIndex))); - connectionManager.BannedFromChannel += ConnectionManager_BannedFromChannel; + connectionManager.BannedFromChannel += (_, e) => ConnectionManager_BannedFromChannelAsync(e).HandleTask(); loginWindow = new CnCNetLoginWindow(WindowManager); loginWindow.Connect += LoginWindow_Connect; @@ -573,7 +572,7 @@ private void PostUIInit() loginWindow.Disable(); passwordRequestWindow = new PasswordRequestWindow(WindowManager, pmWindow); - passwordRequestWindow.PasswordEntered += PasswordRequestWindow_PasswordEntered; + passwordRequestWindow.PasswordEntered += (_, hostedGame) => JoinGameAsync(hostedGame.HostedGame, hostedGame.Password).HandleTask(); var passwordRequestWindowPanel = new DarkeningPanel(WindowManager); passwordRequestWindowPanel.Alpha = 0.0f; @@ -584,17 +583,16 @@ private void PostUIInit() gameLobby.GameLeft += GameLobby_GameLeft; gameLoadingLobby.GameLeft += GameLoadingLobby_GameLeft; - UserINISettings.Instance.SettingsSaved += Instance_SettingsSaved; - - GameProcessLogic.GameProcessStarted += SharedUILogic_GameProcessStarted; - GameProcessLogic.GameProcessExited += SharedUILogic_GameProcessExited; + UserINISettings.Instance.SettingsSaved += (_, _) => Instance_SettingsSavedAsync().HandleTask(); + GameProcessLogic.GameProcessStarted += () => SharedUILogic_GameProcessStartedAsync().HandleTask(); + GameProcessLogic.GameProcessExited += () => SharedUILogic_GameProcessExitedAsync().HandleTask(); } /// /// Displays a message when the IRC server has informed that the local user /// has been banned from a channel that they're attempting to join. /// - private void ConnectionManager_BannedFromChannel(object sender, ChannelEventArgs e) + private async ValueTask ConnectionManager_BannedFromChannelAsync(ChannelEventArgs e) { var game = lbGameList.HostedGames.Find(hg => ((HostedCnCNetGame)hg).ChannelName == e.ChannelName); @@ -613,25 +611,22 @@ private void ConnectionManager_BannedFromChannel(object sender, ChannelEventArgs if (gameOfLastJoinAttempt != null) { if (gameOfLastJoinAttempt.IsLoadedGame) - gameLoadingLobby.Clear(); + await gameLoadingLobby.ClearAsync().ConfigureAwait(false); else - gameLobby.Clear(); + await gameLobby.ClearAsync(false).ConfigureAwait(false); } } - private void SharedUILogic_GameProcessStarted() - { - connectionManager.SendCustomMessage(new QueuedMessage("AWAY " + (char)58 + "In-game", - QueuedMessageType.SYSTEM_MESSAGE, 0)); - } + private ValueTask SharedUILogic_GameProcessStartedAsync() + => connectionManager.SendCustomMessageAsync(new QueuedMessage( + IRCCommands.AWAY + " " + (char)58 + "In-game", + QueuedMessageType.SYSTEM_MESSAGE, + 0)); - private void SharedUILogic_GameProcessExited() - { - connectionManager.SendCustomMessage(new QueuedMessage("AWAY", - QueuedMessageType.SYSTEM_MESSAGE, 0)); - } + private ValueTask SharedUILogic_GameProcessExitedAsync() + => connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.AWAY, QueuedMessageType.SYSTEM_MESSAGE, 0)); - private void Instance_SettingsSaved(object sender, EventArgs e) + private async ValueTask Instance_SettingsSavedAsync() { if (!connectionManager.IsConnected) return; @@ -647,13 +642,13 @@ private void Instance_SettingsSaved(object sender, EventArgs e) if (followedGames.Contains(game.InternalName) && !UserINISettings.Instance.IsGameFollowed(game.InternalName.ToUpper())) { - connectionManager.FindChannel(game.GameBroadcastChannel).Leave(); + await connectionManager.FindChannel(game.GameBroadcastChannel).LeaveAsync().ConfigureAwait(false); followedGames.Remove(game.InternalName); } else if (!followedGames.Contains(game.InternalName) && UserINISettings.Instance.IsGameFollowed(game.InternalName.ToUpper())) { - connectionManager.FindChannel(game.GameBroadcastChannel).Join(); + await connectionManager.FindChannel(game.GameBroadcastChannel).JoinAsync().ConfigureAwait(false); followedGames.Add(game.InternalName); } } @@ -760,12 +755,6 @@ private void SetLogOutButtonText() btnLogout.Text = "Log Out".L10N("Client:Main:LogOut"); } - private void BtnJoinGame_LeftClick(object sender, EventArgs e) => JoinSelectedGame(); - - private void LbGameList_DoubleLeftClick(object sender, EventArgs e) => JoinSelectedGame(); - - private void PasswordRequestWindow_PasswordEntered(object sender, PasswordEventArgs e) => _JoinGame(e.HostedGame, e.Password); - private string GetJoinGameErrorBase() { if (isJoiningGame) @@ -812,16 +801,16 @@ private string GetJoinGameError(HostedCnCNetGame hg) return GetJoinGameErrorBase(); } - private void JoinSelectedGame() + private async ValueTask JoinSelectedGameAsync() { var listedGame = (HostedCnCNetGame)lbGameList.SelectedItem?.Tag; if (listedGame == null) return; var hostedGameIndex = lbGameList.HostedGames.IndexOf(listedGame); - JoinGameByIndex(hostedGameIndex, string.Empty); + await JoinGameByIndexAsync(hostedGameIndex, string.Empty).ConfigureAwait(false); } - private bool JoinGameByIndex(int gameIndex, string password) + private async ValueTask JoinGameByIndexAsync(int gameIndex, string password) { string error = GetJoinGameErrorByIndex(gameIndex); if (!string.IsNullOrEmpty(error)) @@ -830,7 +819,7 @@ private bool JoinGameByIndex(int gameIndex, string password) return false; } - return JoinGame((HostedCnCNetGame)lbGameList.HostedGames[gameIndex], password, connectionManager.MainChannel); + return await JoinGameAsync((HostedCnCNetGame)lbGameList.HostedGames[gameIndex], password, connectionManager.MainChannel).ConfigureAwait(false); } /// @@ -839,8 +828,7 @@ private bool JoinGameByIndex(int gameIndex, string password) /// The game to join. /// The password to join with. /// The message view/list to write error messages to. - /// - private bool JoinGame(HostedCnCNetGame hg, string password, IMessageView messageView) + private async ValueTask JoinGameAsync(HostedCnCNetGame hg, string password, IMessageView messageView) { string error = GetJoinGameError(hg); if (!string.IsNullOrEmpty(error)) @@ -873,22 +861,22 @@ private bool JoinGame(HostedCnCNetGame hg, string password, IMessageView message if (!hg.IsLoadedGame) { password = Utilities.CalculateSHA1ForString - (hg.ChannelName + hg.RoomName).Substring(0, 10); + (hg.ChannelName + hg.RoomName)[..10]; } else { - IniFile spawnSGIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "Saved Games", "spawnSG.ini")); + IniFile spawnSGIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI)); password = Utilities.CalculateSHA1ForString( - spawnSGIni.GetStringValue("Settings", "GameID", string.Empty)).Substring(0, 10); + spawnSGIni.GetStringValue("Settings", "GameID", string.Empty))[..10]; } } - _JoinGame(hg, password); + await JoinGameAsync(hg, password).ConfigureAwait(false); return true; } - private void _JoinGame(HostedCnCNetGame hg, string password) + private async ValueTask JoinGameAsync(HostedCnCNetGame hg, string password) { connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, string.Format("Attempting to join game {0} ...".L10N("Client:Main:AttemptJoin"), hg.RoomName))); @@ -901,35 +889,30 @@ private void _JoinGame(HostedCnCNetGame hg, string password) if (hg.IsLoadedGame) { gameLoadingLobby.SetUp(false, hg.TunnelServer, gameChannel, hg.HostName); - gameChannel.UserAdded += GameLoadingChannel_UserAdded; - //gameChannel.MessageAdded += GameLoadingChannel_MessageAdded; - gameChannel.InvalidPasswordEntered += GameChannel_InvalidPasswordEntered_LoadedGame; + gameChannel.UserAdded += gameLoadingChannel_UserAddedFunc; + gameChannel.InvalidPasswordEntered += gameChannel_InvalidPasswordEntered_LoadedGameFunc; } else { - gameLobby.SetUp(gameChannel, false, hg.MaxPlayers, hg.TunnelServer, hg.HostName, hg.Passworded); - gameChannel.UserAdded += GameChannel_UserAdded; - gameChannel.InvalidPasswordEntered += GameChannel_InvalidPasswordEntered_NewGame; - gameChannel.InviteOnlyErrorOnJoin += GameChannel_InviteOnlyErrorOnJoin; - gameChannel.ChannelFull += GameChannel_ChannelFull; - gameChannel.TargetChangeTooFast += GameChannel_TargetChangeTooFast; + await gameLobby.SetUpAsync(gameChannel, false, hg.MaxPlayers, hg.TunnelServer, hg.HostName, hg.Passworded).ConfigureAwait(false); + gameChannel.UserAdded += gameChannel_UserAddedFunc; + gameChannel.InvalidPasswordEntered += gameChannel_InvalidPasswordEntered_NewGameFunc; + gameChannel.InviteOnlyErrorOnJoin += gameChannel_InviteOnlyErrorOnJoinFunc; + gameChannel.ChannelFull += gameChannel_ChannelFullFunc; + gameChannel.TargetChangeTooFast += gameChannel_TargetChangeTooFastFunc; } - connectionManager.SendCustomMessage(new QueuedMessage("JOIN " + hg.ChannelName + " " + password, - QueuedMessageType.INSTANT_MESSAGE, 0)); + await connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.JOIN + " " + hg.ChannelName + " " + password, + QueuedMessageType.INSTANT_MESSAGE, 0)).ConfigureAwait(false); } - private void GameChannel_TargetChangeTooFast(object sender, MessageEventArgs e) + private async ValueTask GameChannel_TargetChangeTooFastAsync(object sender, MessageEventArgs e) { connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, e.Message)); - ClearGameJoinAttempt((Channel)sender); + await ClearGameJoinAttemptAsync((Channel)sender).ConfigureAwait(false); } - private void GameChannel_ChannelFull(object sender, EventArgs e) => - // We'd do the exact same things here, so we can just call the method below - GameChannel_InviteOnlyErrorOnJoin(sender, e); - - private void GameChannel_InviteOnlyErrorOnJoin(object sender, EventArgs e) + private async ValueTask OnGameLocked(object sender) { connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, "The selected game is locked!".L10N("Client:Main:GameLocked"))); var channel = (Channel)sender; @@ -941,7 +924,7 @@ private void GameChannel_InviteOnlyErrorOnJoin(object sender, EventArgs e) SortAndRefreshHostedGames(); } - ClearGameJoinAttempt((Channel)sender); + await ClearGameJoinAttemptAsync((Channel)sender).ConfigureAwait(false); } private HostedCnCNetGame FindGameByChannelName(string channelName) @@ -953,38 +936,38 @@ private HostedCnCNetGame FindGameByChannelName(string channelName) return (HostedCnCNetGame)game; } - private void GameChannel_InvalidPasswordEntered_NewGame(object sender, EventArgs e) + private async ValueTask GameChannel_InvalidPasswordEntered_NewGameAsync(object sender) { connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, "Incorrect password!".L10N("Client:Main:PasswordWrong"))); - ClearGameJoinAttempt((Channel)sender); + await ClearGameJoinAttemptAsync((Channel)sender).ConfigureAwait(false); } - private void GameChannel_UserAdded(object sender, Online.ChannelUserEventArgs e) + private async ValueTask GameChannel_UserAddedAsync(object sender, ChannelUserEventArgs e) { Channel gameChannel = (Channel)sender; if (e.User.IRCUser.Name == ProgramConstants.PLAYERNAME) { ClearGameChannelEvents(gameChannel); - gameLobby.OnJoined(); + await gameLobby.OnJoinedAsync().ConfigureAwait(false); isInGameRoom = true; SetLogOutButtonText(); } } - private void ClearGameJoinAttempt(Channel channel) + private async ValueTask ClearGameJoinAttemptAsync(Channel channel) { ClearGameChannelEvents(channel); - gameLobby.Clear(); + await gameLobby.ClearAsync(false).ConfigureAwait(false); } private void ClearGameChannelEvents(Channel channel) { - channel.UserAdded -= GameChannel_UserAdded; - channel.InvalidPasswordEntered -= GameChannel_InvalidPasswordEntered_NewGame; - channel.InviteOnlyErrorOnJoin -= GameChannel_InviteOnlyErrorOnJoin; - channel.ChannelFull -= GameChannel_ChannelFull; - channel.TargetChangeTooFast -= GameChannel_TargetChangeTooFast; + channel.UserAdded -= gameChannel_UserAddedFunc; + channel.InvalidPasswordEntered -= gameChannel_InvalidPasswordEntered_NewGameFunc; + channel.InviteOnlyErrorOnJoin -= gameChannel_InviteOnlyErrorOnJoinFunc; + channel.ChannelFull -= gameChannel_ChannelFullFunc; + channel.TargetChangeTooFast -= gameChannel_TargetChangeTooFastFunc; isJoiningGame = false; } @@ -1002,7 +985,7 @@ private void BtnNewGame_LeftClick(object sender, EventArgs e) gcw.Refresh(); } - private void Gcw_GameCreated(object sender, GameCreationEventArgs e) + private async ValueTask Gcw_GameCreatedAsync(GameCreationEventArgs e) { if (gameLobby.Enabled || gameLoadingLobby.Enabled) return; @@ -1012,18 +995,16 @@ private void Gcw_GameCreated(object sender, GameCreationEventArgs e) bool isCustomPassword = true; if (string.IsNullOrEmpty(password)) { - password = Rampastring.Tools.Utilities.CalculateSHA1ForString( - channelName + e.GameRoomName).Substring(0, 10); + password = Utilities.CalculateSHA1ForString(channelName + e.GameRoomName)[..10]; isCustomPassword = false; } Channel gameChannel = connectionManager.CreateChannel(e.GameRoomName, channelName, false, true, password); connectionManager.AddChannel(gameChannel); - gameLobby.SetUp(gameChannel, true, e.MaxPlayers, e.Tunnel, ProgramConstants.PLAYERNAME, isCustomPassword); - gameChannel.UserAdded += GameChannel_UserAdded; - //gameChannel.MessageAdded += GameChannel_MessageAdded; - connectionManager.SendCustomMessage(new QueuedMessage("JOIN " + channelName + " " + password, - QueuedMessageType.INSTANT_MESSAGE, 0)); + await gameLobby.SetUpAsync(gameChannel, true, e.MaxPlayers, e.Tunnel, ProgramConstants.PLAYERNAME, isCustomPassword).ConfigureAwait(false); + gameChannel.UserAdded += gameChannel_UserAddedFunc; + await connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.JOIN + " " + channelName + " " + password, + QueuedMessageType.INSTANT_MESSAGE, 0)).ConfigureAwait(false); connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, string.Format("Creating a game named {0} ...".L10N("Client:Main:CreateGameNamed"), e.GameRoomName))); @@ -1033,7 +1014,7 @@ private void Gcw_GameCreated(object sender, GameCreationEventArgs e) pmWindow.SetInviteChannelInfo(channelName, e.GameRoomName, string.IsNullOrEmpty(e.Password) ? string.Empty : e.Password); } - private void Gcw_LoadedGameCreated(object sender, GameCreationEventArgs e) + private async ValueTask Gcw_LoadedGameCreatedAsync(GameCreationEventArgs e) { if (gameLobby.Enabled || gameLoadingLobby.Enabled) return; @@ -1043,9 +1024,9 @@ private void Gcw_LoadedGameCreated(object sender, GameCreationEventArgs e) Channel gameLoadingChannel = connectionManager.CreateChannel(e.GameRoomName, channelName, false, true, e.Password); connectionManager.AddChannel(gameLoadingChannel); gameLoadingLobby.SetUp(true, e.Tunnel, gameLoadingChannel, ProgramConstants.PLAYERNAME); - gameLoadingChannel.UserAdded += GameLoadingChannel_UserAdded; - connectionManager.SendCustomMessage(new QueuedMessage("JOIN " + channelName + " " + e.Password, - QueuedMessageType.INSTANT_MESSAGE, 0)); + gameLoadingChannel.UserAdded += gameLoadingChannel_UserAddedFunc; + await connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.JOIN + " " + channelName + " " + e.Password, + QueuedMessageType.INSTANT_MESSAGE, 0)).ConfigureAwait(false); connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, string.Format("Creating a game named {0} ...".L10N("Client:Main:CreateGameNamed"), e.GameRoomName))); @@ -1055,25 +1036,25 @@ private void Gcw_LoadedGameCreated(object sender, GameCreationEventArgs e) pmWindow.SetInviteChannelInfo(channelName, e.GameRoomName, string.IsNullOrEmpty(e.Password) ? string.Empty : e.Password); } - private void GameChannel_InvalidPasswordEntered_LoadedGame(object sender, EventArgs e) + private async ValueTask GameChannel_InvalidPasswordEntered_LoadedGameAsync(object sender) { var channel = (Channel)sender; - channel.UserAdded -= GameLoadingChannel_UserAdded; - channel.InvalidPasswordEntered -= GameChannel_InvalidPasswordEntered_LoadedGame; - gameLoadingLobby.Clear(); + channel.UserAdded -= gameLoadingChannel_UserAddedFunc; + channel.InvalidPasswordEntered -= gameChannel_InvalidPasswordEntered_LoadedGameFunc; + await gameLoadingLobby.ClearAsync().ConfigureAwait(false); isJoiningGame = false; } - private void GameLoadingChannel_UserAdded(object sender, ChannelUserEventArgs e) + private async ValueTask GameLoadingChannel_UserAddedAsync(object sender, ChannelUserEventArgs e) { Channel gameLoadingChannel = (Channel)sender; if (e.User.IRCUser.Name == ProgramConstants.PLAYERNAME) { - gameLoadingChannel.UserAdded -= GameLoadingChannel_UserAdded; - gameLoadingChannel.InvalidPasswordEntered -= GameChannel_InvalidPasswordEntered_LoadedGame; + gameLoadingChannel.UserAdded -= gameLoadingChannel_UserAddedFunc; + gameLoadingChannel.InvalidPasswordEntered -= gameChannel_InvalidPasswordEntered_LoadedGameFunc; - gameLoadingLobby.OnJoined(); + await gameLoadingLobby.OnJoinedAsync().ConfigureAwait(false); isInGameRoom = true; isJoiningGame = false; } @@ -1096,14 +1077,14 @@ private string RandomizeChannelName() private void Gcw_Cancelled(object sender, EventArgs e) => gameCreationPanel.Hide(); - private void TbChatInput_EnterPressed(object sender, EventArgs e) + private async ValueTask TbChatInput_EnterPressedAsync() { if (string.IsNullOrEmpty(tbChatInput.Text)) return; IRCColor selectedColor = (IRCColor)ddColor.SelectedItem.Tag; - currentChatChannel.SendChatMessage(tbChatInput.Text, selectedColor); + await currentChatChannel.SendChatMessageAsync(tbChatInput.Text, selectedColor).ConfigureAwait(false); tbChatInput.Text = string.Empty; } @@ -1144,11 +1125,11 @@ private void ConnectionManager_Disconnected(object sender, EventArgs e) ddCurrentChannel.SelectedIndex = gameIndex; } - if (gameCheckCancellation != null) - gameCheckCancellation.Cancel(); + gameCheckCancellation?.Cancel(); + gameCheckCancellation?.Dispose(); } - private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e) + private async ValueTask ConnectionManager_WelcomeMessageReceivedAsync() { btnNewGame.AllowClick = true; btnJoinGame.AllowClick = true; @@ -1156,13 +1137,13 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e tbChatInput.Enabled = true; Channel cncnetChannel = connectionManager.FindChannel("#cncnet"); - cncnetChannel.Join(); + await cncnetChannel.JoinAsync().ConfigureAwait(false); string localGameChatChannelName = gameCollection.GetGameChatChannelNameFromIdentifier(localGameID); - connectionManager.FindChannel(localGameChatChannelName).Join(); + await connectionManager.FindChannel(localGameChatChannelName).JoinAsync().ConfigureAwait(false); string localGameBroadcastChannel = gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGameID); - connectionManager.FindChannel(localGameBroadcastChannel).Join(); + await connectionManager.FindChannel(localGameBroadcastChannel).JoinAsync().ConfigureAwait(false); foreach (CnCNetGame game in gameCollection.GameList) { @@ -1173,15 +1154,14 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e { if (UserINISettings.Instance.IsGameFollowed(game.InternalName.ToUpper())) { - connectionManager.FindChannel(game.GameBroadcastChannel).Join(); + await connectionManager.FindChannel(game.GameBroadcastChannel).JoinAsync().ConfigureAwait(false); followedGames.Add(game.InternalName); } } } gameCheckCancellation = new CancellationTokenSource(); - CnCNetGameCheck gameCheck = new CnCNetGameCheck(); - gameCheck.InitializeService(gameCheckCancellation); + CnCNetGameCheck.RunServiceAsync(gameCheckCancellation.Token).HandleTask(); } private void ConnectionManager_PrivateCTCPReceived(object sender, PrivateCTCPEventArgs e) @@ -1195,7 +1175,7 @@ private void ConnectionManager_PrivateCTCPReceived(object sender, PrivateCTCPEve Logger.Log("Unhandled private CTCP command: " + e.Message + " from " + e.Sender); } - private void HandleGameInviteCommand(string sender, string argumentsString) + private async ValueTask HandleGameInviteCommandAsync(string sender, string argumentsString) { // arguments are semicolon-delimited var arguments = argumentsString.Split(';'); @@ -1222,9 +1202,9 @@ private void HandleGameInviteCommand(string sender, string argumentsString) { // let the host know that we can't accept // note this is not reached for the rejection case - connectionManager.SendCustomMessage(new QueuedMessage("PRIVMSG " + sender + " :\u0001" + - ProgramConstants.GAME_INVITATION_FAILED_CTCP_COMMAND + "\u0001", - QueuedMessageType.CHAT_MESSAGE, 0)); + await connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.PRIVMSG + " " + sender + " :\u0001" + + CnCNetCommands.GAME_INVITATION_FAILED + "\u0001", + QueuedMessageType.CHAT_MESSAGE, 0)).ConfigureAwait(false); return; } @@ -1254,36 +1234,34 @@ private void HandleGameInviteCommand(string sender, string argumentsString) // add the invitation to the index so we can remove it if the target game is closed // also lets us silently ignore new invitations from the same person while this one is still outstanding - invitationIndex[invitationIdentity] = - new WeakReference(gameInviteChoiceBox); + invitationIndex[invitationIdentity] = new WeakReference(gameInviteChoiceBox); - gameInviteChoiceBox.AffirmativeClickedAction = delegate (ChoiceNotificationBox choiceBox) - { - // if we're currently in a game lobby, first leave that channel - if (isInGameRoom) - { - gameLobby.LeaveGameLobby(); - } + gameInviteChoiceBox.AffirmativeClickedAction = _ => AffirmativeClickedActionAsync(channelName, password, sender, invitationIdentity).HandleTask(); - // JoinGameByIndex does bounds checking so we're safe to pass -1 if the game doesn't exist - if (!JoinGameByIndex(lbGameList.HostedGames.FindIndex(hg => ((HostedCnCNetGame)hg).ChannelName == channelName), password)) - { - XNAMessageBox.Show(WindowManager, - "Failed to join".L10N("Client:Main:JoinFailedTitle"), - string.Format("Unable to join {0}'s game. The game may be locked or closed.".L10N("Client:Main:JoinFailedText"), sender)); - } + // clean up the index as this invitation no longer exists + gameInviteChoiceBox.NegativeClickedAction = _ => invitationIndex.Remove(invitationIdentity); - // clean up the index as this invitation no longer exists - invitationIndex.Remove(invitationIdentity); - }; + sndGameInviteReceived.Play(); + } - gameInviteChoiceBox.NegativeClickedAction = delegate (ChoiceNotificationBox choiceBox) + private async ValueTask AffirmativeClickedActionAsync(string channelName, string password, string sender, UserChannelPair invitationIdentity) + { + // if we're currently in a game lobby, first leave that channel + if (isInGameRoom) { - // clean up the index as this invitation no longer exists - invitationIndex.Remove(invitationIdentity); - }; + await gameLobby.LeaveGameLobbyAsync().ConfigureAwait(false); + } - sndGameInviteReceived.Play(); + // JoinGameByIndex does bounds checking so we're safe to pass -1 if the game doesn't exist + if (!await JoinGameByIndexAsync(lbGameList.HostedGames.FindIndex(hg => ((HostedCnCNetGame)hg).ChannelName == channelName), password).ConfigureAwait(false)) + { + XNAMessageBox.Show(WindowManager, + "Failed to join".L10N("Client:Main:JoinFailedTitle"), + string.Format("Unable to join {0}'s game. The game may be locked or closed.".L10N("Client:Main:JoinFailedText"), sender)); + } + + // clean up the index as this invitation no longer exists + invitationIndex.Remove(invitationIdentity); } private void HandleGameInvitationFailedNotification(string sender) @@ -1300,7 +1278,7 @@ private void HandleGameInvitationFailedNotification(string sender) } } - private void DdCurrentChannel_SelectedIndexChanged(object sender, EventArgs e) + private async ValueTask DdCurrentChannel_SelectedIndexChangedAsync() { if (currentChatChannel != null) { @@ -1321,7 +1299,7 @@ private void DdCurrentChannel_SelectedIndexChanged(object sender, EventArgs e) connectionManager.RemoveChannelFromUser(user.IRCUser.Name, currentChatChannel.ChannelName); }); - currentChatChannel.Leave(); + await currentChatChannel.LeaveAsync().ConfigureAwait(false); } } @@ -1346,7 +1324,7 @@ private void DdCurrentChannel_SelectedIndexChanged(object sender, EventArgs e) if (currentChatChannel.ChannelName != "#cncnet" && currentChatChannel.ChannelName != gameCollection.GetGameChatChannelNameFromIdentifier(localGameID)) { - currentChatChannel.Join(); + await currentChatChannel.JoinAsync().ConfigureAwait(false); } } @@ -1444,10 +1422,10 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr !updateDenied && channelUser.IsAdmin && !isInGameRoom && - e.Message.StartsWith("UPDATE ") && + e.Message.StartsWith(CnCNetCommands.UPDATE + " ") && e.Message.Length > 7) { - string version = e.Message.Substring(7); + string version = e.Message[7..]; if (version != ProgramConstants.GAME_VERSION) { var updateMessageBox = XNAMessageBox.ShowYesNoDialog(WindowManager, "Update available".L10N("Client:Main:UpdateAvailableTitle"), @@ -1457,10 +1435,10 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr } } - if (!e.Message.StartsWith("GAME ")) + if (!e.Message.StartsWith(CnCNetCommands.GAME + " ")) return; - string msg = e.Message.Substring(5); // Cut out GAME part + string msg = e.Message[5..]; // Cut out GAME part string[] splitMessage = msg.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); if (splitMessage.Length != 11) @@ -1472,41 +1450,43 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr try { string revision = splitMessage[0]; + if (revision != ProgramConstants.CNCNET_PROTOCOL_REVISION) return; + string gameVersion = splitMessage[1]; int maxPlayers = Conversions.IntFromString(splitMessage[2], 0); string gameRoomChannelName = splitMessage[3]; string gameRoomDisplayName = splitMessage[4]; - bool locked = Conversions.BooleanFromString(splitMessage[5].Substring(0, 1), true); + bool locked = Conversions.BooleanFromString(splitMessage[5][..1], true); bool isCustomPassword = Conversions.BooleanFromString(splitMessage[5].Substring(1, 1), false); bool isClosed = Conversions.BooleanFromString(splitMessage[5].Substring(2, 1), true); bool isLoadedGame = Conversions.BooleanFromString(splitMessage[5].Substring(3, 1), false); bool isLadder = Conversions.BooleanFromString(splitMessage[5].Substring(4, 1), false); - string[] players = splitMessage[6].Split(new char[1] { ',' }, StringSplitOptions.RemoveEmptyEntries); - List playerNames = players.ToList(); + string[] players = splitMessage[6].Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); string mapName = splitMessage[7]; string gameMode = splitMessage[8]; - - string[] tunnelAddressAndPort = splitMessage[9].Split(':'); - string tunnelAddress = tunnelAddressAndPort[0]; - int tunnelPort = int.Parse(tunnelAddressAndPort[1]); - + string tunnelHash = splitMessage[9]; string loadedGameId = splitMessage[10]; CnCNetGame cncnetGame = gameCollection.GameList.Find(g => g.GameBroadcastChannel == channel.ChannelName); - CnCNetTunnel tunnel = tunnelHandler.Tunnels.Find(t => t.Address == tunnelAddress && t.Port == tunnelPort); - - if (tunnel == null) - return; - if (cncnetGame == null) return; - HostedCnCNetGame game = new HostedCnCNetGame(gameRoomChannelName, revision, gameVersion, maxPlayers, - gameRoomDisplayName, isCustomPassword, true, players, - e.UserName, mapName, gameMode); + CnCNetTunnel tunnel = null; + + if (!ProgramConstants.CNCNET_DYNAMIC_TUNNELS.Equals(tunnelHash, StringComparison.OrdinalIgnoreCase)) + { + tunnel = tunnelHandler.Tunnels.Find(t => t.Hash.Equals(tunnelHash, StringComparison.OrdinalIgnoreCase)); + + if (tunnel == null) + return; + } + + var game = new HostedCnCNetGame(gameRoomChannelName, revision, gameVersion, maxPlayers, + gameRoomDisplayName, isCustomPassword, true, players, e.UserName, mapName, gameMode); + game.IsLoadedGame = isLoadedGame; game.MatchID = loadedGameId; game.LastRefreshTime = DateTime.Now; @@ -1550,11 +1530,12 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr lbGameList.AddGame(game); } + SortAndRefreshHostedGames(); } catch (Exception ex) { - Logger.Log("Game parsing error: " + ex.Message); + ProgramConstants.LogException(ex, "Game parsing error"); } } @@ -1563,7 +1544,7 @@ private void UpdateMessageBox_YesClicked(XNAMessageBox messageBox) => private void UpdateMessageBox_NoClicked(XNAMessageBox messageBox) => updateDenied = true; - private void BtnLogout_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLogout_LeftClickAsync() { if (isInGameRoom) { @@ -1574,7 +1555,7 @@ private void BtnLogout_LeftClick(object sender, EventArgs e) if (connectionManager.IsConnected && !UserINISettings.Instance.PersistentMode) { - connectionManager.Disconnect(); + await connectionManager.DisconnectAsync().ConfigureAwait(false); } topBar.SwitchToPrimary(); @@ -1655,14 +1636,10 @@ private void DismissInvalidInvitations() private void DismissInvitation(UserChannelPair invitationIdentity) { - if (invitationIndex.ContainsKey(invitationIdentity)) + if (invitationIndex.TryGetValue(invitationIdentity, out WeakReference _)) { - var invitationNotification = invitationIndex[invitationIdentity].Target as ChoiceNotificationBox; - - if (invitationNotification != null) - { + if (invitationIndex[invitationIdentity].Target is ChoiceNotificationBox invitationNotification) WindowManager.RemoveControl(invitationNotification); - } invitationIndex.Remove(invitationIdentity); } @@ -1684,7 +1661,7 @@ private HostedCnCNetGame GetHostedGameForUser(IRCUser user) /// /// The user to join. /// The message view/list to write error messages to. - private void JoinUser(IRCUser user, IMessageView messageView) + private async ValueTask JoinUserAsync(IRCUser user, IMessageView messageView) { if (user == null) { @@ -1699,7 +1676,7 @@ private void JoinUser(IRCUser user, IMessageView messageView) return; } - JoinGame(game, string.Empty, messageView); + await JoinGameAsync(game, string.Empty, messageView).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/GameCreationWindow.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/GameCreationWindow.cs index 2a5a95917..69877ec2f 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/GameCreationWindow.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/GameCreationWindow.cs @@ -199,7 +199,7 @@ private void BtnLoadMPGame_LeftClick(object sender, EventArgs e) new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI)); string password = Utilities.CalculateSHA1ForString( - spawnSGIni.GetStringValue("Settings", "GameID", string.Empty)).Substring(0, 10); + spawnSGIni.GetStringValue("Settings", "GameID", string.Empty))[..10]; GameCreationEventArgs ea = new GameCreationEventArgs(gameName, spawnSGIni.GetIntValue("Settings", "PlayerCount", 2), password, diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/GlobalContextMenu.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/GlobalContextMenu.cs index 1f8d533f8..7a371436f 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/GlobalContextMenu.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/GlobalContextMenu.cs @@ -1,8 +1,10 @@ using System; using System.Linq; +using System.Threading.Tasks; using ClientCore; using ClientCore.Extensions; using ClientGUI; +using DTAClient.Domain.Multiplayer.CnCNet; using DTAClient.Online; using DTAClient.Online.EventArguments; using ClientCore.Extensions; @@ -13,7 +15,7 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet { - public class GlobalContextMenu : XNAContextMenu + internal sealed class GlobalContextMenu : XNAContextMenu { private readonly string PRIVATE_MESSAGE = "Private Message".L10N("Client:Main:PrivateMessage"); private readonly string ADD_FRIEND = "Add Friend".L10N("Client:Main:AddFriend"); @@ -35,8 +37,8 @@ public class GlobalContextMenu : XNAContextMenu private XNAContextMenuItem copyLinkItem; private XNAContextMenuItem openLinkItem; - protected readonly CnCNetManager connectionManager; - protected GlobalContextMenuData contextMenuData; + private readonly CnCNetManager connectionManager; + private GlobalContextMenuData contextMenuData; public EventHandler JoinEvent; @@ -72,12 +74,12 @@ public override void Initialize() toggleIgnoreItem = new XNAContextMenuItem() { Text = BLOCK, - SelectAction = () => GetIrcUserIdent(cncnetUserData.ToggleIgnoreUser) + SelectAction = () => GetIrcUserIdentAsync(cncnetUserData.ToggleIgnoreUser).HandleTask() }; invitePlayerItem = new XNAContextMenuItem() { Text = INVITE, - SelectAction = Invite + SelectAction = () => InviteAsync().HandleTask() }; joinPlayerItem = new XNAContextMenuItem() { @@ -104,7 +106,7 @@ public override void Initialize() AddItem(openLinkItem); } - private void Invite() + private async ValueTask InviteAsync() { // note it's assumed that if the channel name is specified, the game name must be also if (string.IsNullOrEmpty(contextMenuData.inviteChannelName) || ProgramConstants.IsInGame) @@ -112,16 +114,15 @@ private void Invite() return; } - string messageBody = ProgramConstants.GAME_INVITE_CTCP_COMMAND + " " + contextMenuData.inviteChannelName + ";" + contextMenuData.inviteGameName; + string messageBody = CnCNetCommands.GAME_INVITE + " " + contextMenuData.inviteChannelName + ";" + contextMenuData.inviteGameName; if (!string.IsNullOrEmpty(contextMenuData.inviteChannelPassword)) { messageBody += ";" + contextMenuData.inviteChannelPassword; } - connectionManager.SendCustomMessage(new QueuedMessage( - "PRIVMSG " + GetIrcUser().Name + " :\u0001" + messageBody + "\u0001", QueuedMessageType.CHAT_MESSAGE, 0 - )); + await connectionManager.SendCustomMessageAsync(new QueuedMessage( + IRCCommands.PRIVMSG + " " + GetIrcUser().Name + " :\u0001" + messageBody + "\u0001", QueuedMessageType.CHAT_MESSAGE, 0)).ConfigureAwait(false); } private void UpdateButtons() @@ -179,13 +180,14 @@ private void CopyLink(string link) { ClipboardService.SetText(link); } - catch (Exception) + catch (Exception ex) { + ProgramConstants.LogException(ex, "Unable to copy link."); XNAMessageBox.Show(WindowManager, "Error".L10N("Client:Main:Error"), "Unable to copy link".L10N("Client:Main:ClipboardCopyLinkFailed")); } } - private void GetIrcUserIdent(Action callback) + private async ValueTask GetIrcUserIdentAsync(Action callback) { var ircUser = GetIrcUser(); @@ -203,7 +205,7 @@ void WhoIsReply(object sender, WhoEventArgs whoEventargs) } connectionManager.WhoReplyReceived += WhoIsReply; - connectionManager.SendWhoIsMessage(ircUser.Name); + await connectionManager.SendWhoIsMessageAsync(ircUser.Name).ConfigureAwait(false); } private IRCUser GetIrcUser() diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/PasswordRequestWindow.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/PasswordRequestWindow.cs index 946a7bf37..76ebac053 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/PasswordRequestWindow.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/PasswordRequestWindow.cs @@ -81,8 +81,8 @@ private void PasswordRequestWindow_EnabledChanged(object sender, EventArgs e) if (!privateMessagingWindow.Enabled) return; pmWindowWasEnabled = true; privateMessagingWindow.Disable(); - } - else if(pmWindowWasEnabled) + } + else if (pmWindowWasEnabled) { privateMessagingWindow.Enable(); } @@ -111,7 +111,7 @@ public void SetHostedGame(HostedCnCNetGame hostedGame) } } - public class PasswordEventArgs : EventArgs + internal sealed class PasswordEventArgs : EventArgs { public PasswordEventArgs(string password, HostedCnCNetGame hostedGame) { diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/PrivateMessagingWindow.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/PrivateMessagingWindow.cs index 5c460ce6f..b3cd41534 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/PrivateMessagingWindow.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/PrivateMessagingWindow.cs @@ -12,15 +12,17 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Threading.Tasks; using ClientCore.Enums; using ClientCore.Extensions; +using DTAClient.Domain.Multiplayer.CnCNet; using SixLabors.ImageSharp; using Color = Microsoft.Xna.Framework.Color; using Rectangle = Microsoft.Xna.Framework.Rectangle; namespace DTAClient.DXGUI.Multiplayer.CnCNet { - public class PrivateMessagingWindow : XNAWindow, ISwitchable + internal sealed class PrivateMessagingWindow : XNAWindow, ISwitchable { private const int MESSAGES_INDEX = 0; private const int FRIEND_LIST_VIEW_INDEX = 1; @@ -180,7 +182,7 @@ public override void Initialize() tbMessageInput.Name = nameof(tbMessageInput); tbMessageInput.ClientRectangle = new Rectangle(lbMessages.X, lbMessages.Bottom + 6, lbMessages.Width, 19); - tbMessageInput.EnterPressed += TbMessageInput_EnterPressed; + tbMessageInput.EnterPressed += (_, _) => TbMessageInput_EnterPressedAsync().HandleTask(); tbMessageInput.MaximumTextLength = 200; tbMessageInput.Enabled = false; @@ -414,7 +416,7 @@ private void PlayerContextMenu_JoinUser(object sender, JoinUserEventArgs args) } private void SharedUILogic_GameProcessExited() => - WindowManager.AddCallback(new Action(HandleGameProcessExited), null); + WindowManager.AddCallback(HandleGameProcessExited); private void HandleGameProcessExited() { @@ -515,7 +517,7 @@ private void ShowNotification(IRCUser ircUser, string message) private int FindItemIndexForName(string userName) => lbUserList.Items.FindIndex(MatchItemForName(userName)); - private void TbMessageInput_EnterPressed(object sender, EventArgs e) + private async ValueTask TbMessageInput_EnterPressedAsync() { if (string.IsNullOrEmpty(tbMessageInput.Text)) return; @@ -525,8 +527,8 @@ private void TbMessageInput_EnterPressed(object sender, EventArgs e) string userName = lbUserList.SelectedItem.Text; - connectionManager.SendCustomMessage(new QueuedMessage("PRIVMSG " + userName + " :" + tbMessageInput.Text, - QueuedMessageType.CHAT_MESSAGE, 0)); + await connectionManager.SendCustomMessageAsync(new QueuedMessage(IRCCommands.PRIVMSG + " " + userName + " :" + tbMessageInput.Text, + QueuedMessageType.CHAT_MESSAGE, 0)).ConfigureAwait(false); PrivateMessageUser pmUser = privateMessageUsers.Find(u => u.IrcUser.Name == userName); if (pmUser == null) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/RecentPlayerTable.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/RecentPlayerTable.cs index d7be10987..85068a0f8 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/RecentPlayerTable.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/RecentPlayerTable.cs @@ -7,7 +7,7 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet { - public class RecentPlayerTable : XNAMultiColumnListBox + internal sealed class RecentPlayerTable : XNAMultiColumnListBox { private readonly CnCNetManager connectionManager; diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelListBox.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelListBox.cs index 1e1a0ba46..c399f597e 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelListBox.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelListBox.cs @@ -14,7 +14,8 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet /// class TunnelListBox : XNAMultiColumnListBox { - public TunnelListBox(WindowManager windowManager, TunnelHandler tunnelHandler) : base(windowManager) + public TunnelListBox(WindowManager windowManager, TunnelHandler tunnelHandler) + : base(windowManager) { this.tunnelHandler = tunnelHandler; @@ -41,35 +42,34 @@ public TunnelListBox(WindowManager windowManager, TunnelHandler tunnelHandler) : private readonly TunnelHandler tunnelHandler; - private int bestTunnelIndex = 0; + private int bestTunnelIndex; private int lowestTunnelRating = int.MaxValue; private bool isManuallySelectedTunnel; - private string manuallySelectedTunnelAddress; - + private string manuallySelectedTunnelHash; /// /// Selects a tunnel from the list with the given address. /// - /// The address of the tunnel server to select. - public void SelectTunnel(string address) + /// The tunnel server to select. + public void SelectTunnel(CnCNetTunnel cnCNetTunnel) { - int index = tunnelHandler.Tunnels.FindIndex(t => t.Address == address); + int index = tunnelHandler.Tunnels.FindIndex(t => t == cnCNetTunnel); if (index > -1) { SelectedIndex = index; isManuallySelectedTunnel = true; - manuallySelectedTunnelAddress = address; + manuallySelectedTunnelHash = cnCNetTunnel.Hash; } } /// /// Gets whether or not a tunnel from the list with the given address is selected. /// - /// The address of the tunnel server + /// The hash of the tunnel server /// True if tunnel with given address is selected, otherwise false. - public bool IsTunnelSelected(string address) => - tunnelHandler.Tunnels.FindIndex(t => t.Address == address) == SelectedIndex; + public bool IsTunnelSelected(string hash) => + tunnelHandler.Tunnels.FindIndex(t => t.Hash.Equals(hash, StringComparison.OrdinalIgnoreCase)) == SelectedIndex; private void TunnelHandler_TunnelsRefreshed(object sender, EventArgs e) { @@ -113,7 +113,7 @@ private void TunnelHandler_TunnelsRefreshed(object sender, EventArgs e) } else { - int manuallySelectedIndex = tunnelHandler.Tunnels.FindIndex(t => t.Address == manuallySelectedTunnelAddress); + int manuallySelectedIndex = tunnelHandler.Tunnels.FindIndex(t => t.Hash.Equals(manuallySelectedTunnelHash, StringComparison.OrdinalIgnoreCase)); if (manuallySelectedIndex == -1) { @@ -121,7 +121,9 @@ private void TunnelHandler_TunnelsRefreshed(object sender, EventArgs e) isManuallySelectedTunnel = false; } else + { SelectedIndex = manuallySelectedIndex; + } } } @@ -134,7 +136,9 @@ private void TunnelHandler_TunnelPinged(int tunnelIndex) CnCNetTunnel tunnel = tunnelHandler.Tunnels[tunnelIndex]; if (tunnel.PingInMs == -1) + { lbItem.Text = "Unknown".L10N("Client:Main:UnknownPing"); + } else { lbItem.Text = tunnel.PingInMs + " ms"; @@ -152,7 +156,7 @@ private void TunnelHandler_TunnelPinged(int tunnelIndex) } } - private int GetTunnelRating(CnCNetTunnel tunnel) + private static int GetTunnelRating(CnCNetTunnel tunnel) { double usageRatio = (double)tunnel.Clients / tunnel.MaxClients; @@ -170,7 +174,7 @@ private void TunnelListBox_SelectedIndexChanged(object sender, EventArgs e) return; isManuallySelectedTunnel = true; - manuallySelectedTunnelAddress = tunnelHandler.Tunnels[SelectedIndex].Address; + manuallySelectedTunnelHash = tunnelHandler.Tunnels[SelectedIndex].Hash; } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelSelectionWindow.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelSelectionWindow.cs index b445eed62..be8a0c552 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelSelectionWindow.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/TunnelSelectionWindow.cs @@ -24,7 +24,7 @@ public TunnelSelectionWindow(WindowManager windowManager, TunnelHandler tunnelHa private XNALabel lblDescription; private XNAClientButton btnApply; - private string originalTunnelAddress; + private string originalTunnelHash; public override void Initialize() { @@ -90,21 +90,21 @@ private void BtnApply_LeftClick(object sender, EventArgs e) private void BtnCancel_LeftClick(object sender, EventArgs e) => Disable(); private void LbTunnelList_SelectedIndexChanged(object sender, EventArgs e) => - btnApply.AllowClick = !lbTunnelList.IsTunnelSelected(originalTunnelAddress) && lbTunnelList.IsValidIndexSelected(); + btnApply.AllowClick = !lbTunnelList.IsTunnelSelected(originalTunnelHash) && lbTunnelList.IsValidIndexSelected(); /// /// Sets the window's description and selects the tunnel server /// with the given address. /// /// The window description. - /// The address of the tunnel server to select. - public void Open(string description, string tunnelAddress = null) + /// The tunnel server to select. + public void Open(string description, CnCNetTunnel cnCNetTunnel) { lblDescription.Text = description; - originalTunnelAddress = tunnelAddress; + originalTunnelHash = cnCNetTunnel.Hash; - if (!string.IsNullOrWhiteSpace(tunnelAddress)) - lbTunnelList.SelectTunnel(tunnelAddress); + if (cnCNetTunnel is not null) + lbTunnelList.SelectTunnel(cnCNetTunnel); else lbTunnelList.SelectedIndex = -1; @@ -130,4 +130,4 @@ public TunnelEventArgs(CnCNetTunnel tunnel) public CnCNetTunnel Tunnel { get; } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/GameInformationPanel.cs b/DXMainClient/DXGUI/Multiplayer/GameInformationPanel.cs index 2ac774317..33024f77e 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameInformationPanel.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameInformationPanel.cs @@ -114,8 +114,7 @@ public void SetInfo(GenericHostedGame game) lblHost.Text = "Host:".L10N("Client:Main:GameInfoHost") + " " + Renderer.GetSafeString(game.HostName, lblHost.FontIndex); lblHost.Visible = true; - - lblPing.Text = game.Ping > 0 ? "Ping:".L10N("Client:Main:GameInfoPing") + " " + game.Ping.ToString() + " ms" : "Ping: Unknown".L10N("Client:Main:GameInfoPingUnknown"); + lblPing.Text = game.Ping > 0 ? "Ping:".L10N("Client:Main:GameInfoPing") + " " + game.Ping + " ms" : "Ping: Unknown".L10N("Client:Main:GameInfoPingUnknown"); lblPing.Visible = true; lblPlayers.Visible = true; diff --git a/DXMainClient/DXGUI/Multiplayer/GameListBox.cs b/DXMainClient/DXGUI/Multiplayer/GameListBox.cs index eb5f7df3d..ad1f97218 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameListBox.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameListBox.cs @@ -236,8 +236,6 @@ private void AddGameToList(GenericHostedGame hg) if (hg.Game.InternalName != localGameIdentifier.ToLower()) lbItem.TextColor = UISettings.ActiveSettings.TextColor; - //else // made unnecessary by new Rampastring.XNAUI - // lbItem.TextColor = UISettings.ActiveSettings.AltColor; if (hg.Incompatible || hg.Locked) { diff --git a/DXMainClient/DXGUI/Multiplayer/GameLoadingLobbyBase.cs b/DXMainClient/DXGUI/Multiplayer/GameLoadingLobbyBase.cs index 64935edbd..956d58307 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLoadingLobbyBase.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLoadingLobbyBase.cs @@ -11,13 +11,15 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Multiplayer { /// /// An abstract base class for a multiplayer game loading lobby. /// - public abstract class GameLoadingLobbyBase : XNAWindow, ISwitchable + internal abstract class GameLoadingLobbyBase : XNAWindow, ISwitchable { public GameLoadingLobbyBase(WindowManager windowManager, DiscordHandler discordHandler) : base(windowManager) { @@ -65,12 +67,10 @@ public GameLoadingLobbyBase(WindowManager windowManager, DiscordHandler discordH private List MPColors = new List(); - private string loadedGameID; - - private bool isSettingUp = false; + private bool isSettingUp; private FileSystemWatcher fsw; - private int uniqueGameId = 0; + private int uniqueGameId; private DateTime gameLoadTime; public override void Initialize() @@ -146,7 +146,7 @@ public override void Initialize() ddSavedGame.ClientRectangle = new Rectangle(lblSavedGameTime.X, panelPlayers.Bottom - 21, Width - lblSavedGameTime.X - 12, 21); - ddSavedGame.SelectedIndexChanged += DdSavedGame_SelectedIndexChanged; + ddSavedGame.SelectedIndexChanged += (_, _) => DdSavedGame_SelectedIndexChangedAsync().HandleTask(); lbChatMessages = new ChatListBox(WindowManager); lbChatMessages.Name = nameof(lbChatMessages); @@ -161,21 +161,21 @@ public override void Initialize() tbChatInput.ClientRectangle = new Rectangle(lbChatMessages.X, lbChatMessages.Bottom + 3, lbChatMessages.Width, 19); tbChatInput.MaximumTextLength = 200; - tbChatInput.EnterPressed += TbChatInput_EnterPressed; + tbChatInput.EnterPressed += (_, _) => TbChatInput_EnterPressedAsync().HandleTask(); btnLoadGame = new XNAClientButton(WindowManager); btnLoadGame.Name = nameof(btnLoadGame); btnLoadGame.ClientRectangle = new Rectangle(lbChatMessages.X, tbChatInput.Bottom + 6, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnLoadGame.Text = "Load Game".L10N("Client:Main:LoadGame"); - btnLoadGame.LeftClick += BtnLoadGame_LeftClick; + btnLoadGame.LeftClick += (_, _) => BtnLoadGame_LeftClickAsync().HandleTask(); btnLeaveGame = new XNAClientButton(WindowManager); btnLeaveGame.Name = nameof(btnLeaveGame); btnLeaveGame.ClientRectangle = new Rectangle(Width - 145, btnLoadGame.Y, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnLeaveGame.Text = "Leave Game".L10N("Client:Main:LeaveGame"); - btnLeaveGame.LeftClick += BtnLeaveGame_LeftClick; + btnLeaveGame.LeftClick += (_, _) => LeaveGameAsync().HandleTask(); AddChild(lblMapName); AddChild(lblMapNameValue); @@ -201,7 +201,7 @@ public override void Initialize() if (SavedGameManager.AreSavedGamesAvailable()) { - fsw = new FileSystemWatcher(SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "Saved Games"), "*.NET"); + fsw = new FileSystemWatcher(SafePath.CombineDirectoryPath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY), "*.NET"); fsw.EnableRaisingEvents = false; fsw.Created += fsw_Created; fsw.Changed += fsw_Created; @@ -217,55 +217,53 @@ public override void Initialize() /// /// Resets Discord Rich Presence to default state. /// - protected void ResetDiscordPresence() => discordHandler.UpdatePresence(); + private void ResetDiscordPresence() => discordHandler.UpdatePresence(); - private void BtnLeaveGame_LeftClick(object sender, EventArgs e) => LeaveGame(); - - protected virtual void LeaveGame() + protected virtual ValueTask LeaveGameAsync() { GameLeft?.Invoke(this, EventArgs.Empty); ResetDiscordPresence(); + + return ValueTask.CompletedTask; } private void fsw_Created(object sender, FileSystemEventArgs e) => - AddCallback(new Action(HandleFSWEvent), e); + AddCallback(() => HandleFSWEventAsync(e).HandleTask()); - private void HandleFSWEvent(FileSystemEventArgs e) + private static async ValueTask HandleFSWEventAsync(FileSystemEventArgs e) { Logger.Log("FSW Event: " + e.FullPath); if (Path.GetFileName(e.FullPath) == "SAVEGAME.NET") - { - SavedGameManager.RenameSavedGame(); - } + await SavedGameManager.RenameSavedGameAsync().ConfigureAwait(false); } - private void BtnLoadGame_LeftClick(object sender, EventArgs e) + private async ValueTask BtnLoadGame_LeftClickAsync() { if (!IsHost) { - RequestReadyStatus(); + await RequestReadyStatusAsync().ConfigureAwait(false); return; } if (Players.Find(p => !p.Ready) != null) { - GetReadyNotification(); + await GetReadyNotificationAsync().ConfigureAwait(false); return; } if (Players.Count != SGPlayers.Count) { - NotAllPresentNotification(); + await NotAllPresentNotificationAsync().ConfigureAwait(false); return; } - HostStartGame(); + await HostStartGameAsync().ConfigureAwait(false); } - protected abstract void RequestReadyStatus(); + protected abstract ValueTask RequestReadyStatusAsync(); - protected virtual void GetReadyNotification() + protected virtual ValueTask GetReadyNotificationAsync() { AddNotice("The game host wants to load the game but cannot because not all players are ready!".L10N("Client:Main:GetReadyPlease")); @@ -275,20 +273,24 @@ protected virtual void GetReadyNotification() WindowManager.FlashWindow(); #endif + return ValueTask.CompletedTask; } - protected virtual void NotAllPresentNotification() => + protected virtual ValueTask NotAllPresentNotificationAsync() + { AddNotice("You cannot load the game before all players are present.".L10N("Client:Main:NotAllPresent")); + return ValueTask.CompletedTask; + } - protected abstract void HostStartGame(); + protected abstract ValueTask HostStartGameAsync(); - protected void LoadGame() + protected async ValueTask LoadGameAsync() { - FileInfo spawnFileInfo = SafePath.GetFile(ProgramConstants.GamePath, "spawn.ini"); + FileInfo spawnFileInfo = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS); spawnFileInfo.Delete(); - File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, "Saved Games", "spawnSG.ini"), spawnFileInfo.FullName); + File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI), spawnFileInfo.FullName); IniFile spawnIni = new IniFile(spawnFileInfo.FullName); @@ -317,35 +319,38 @@ protected void LoadGame() if (otherPlayer == null) continue; - spawnIni.SetStringValue("Other" + i, "Ip", otherPlayer.IPAddress); + spawnIni.SetStringValue("Other" + i, "Ip", otherPlayer.IPAddress.ToString()); spawnIni.SetIntValue("Other" + i, "Port", otherPlayer.Port); } WriteSpawnIniAdditions(spawnIni); spawnIni.WriteIniFile(); - FileInfo spawnMapFileInfo = SafePath.GetFile(ProgramConstants.GamePath, "spawnmap.ini"); + FileInfo spawnMapFileInfo = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNMAP_INI); spawnMapFileInfo.Delete(); - using StreamWriter spawnMapStreamWriter = new StreamWriter(spawnMapFileInfo.FullName); - spawnMapStreamWriter.WriteLine("[Map]"); - spawnMapStreamWriter.WriteLine("Size=0,0,50,50"); - spawnMapStreamWriter.WriteLine("LocalSize=0,0,50,50"); - spawnMapStreamWriter.WriteLine(); + var spawnMapStreamWriter = new StreamWriter(spawnMapFileInfo.FullName); + + await using (spawnMapStreamWriter.ConfigureAwait(false)) + { + await spawnMapStreamWriter.WriteLineAsync("[Map]").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync("Size=0,0,50,50").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync("LocalSize=0,0,50,50").ConfigureAwait(false); + await spawnMapStreamWriter.WriteLineAsync().ConfigureAwait(false); + } gameLoadTime = DateTime.Now; GameProcessLogic.GameProcessExited += SharedUILogic_GameProcessExited; - GameProcessLogic.StartGameProcess(WindowManager); + await GameProcessLogic.StartGameProcessAsync(WindowManager).ConfigureAwait(false); fsw.EnableRaisingEvents = true; UpdateDiscordPresence(true); } - private void SharedUILogic_GameProcessExited() => - AddCallback(new Action(HandleGameProcessExited), null); + private void SharedUILogic_GameProcessExited() => AddCallback(() => HandleGameProcessExitedAsync().HandleTask()); - protected virtual void HandleGameProcessExited() + protected virtual async ValueTask HandleGameProcessExitedAsync() { fsw.EnableRaisingEvents = false; @@ -355,17 +360,16 @@ protected virtual void HandleGameProcessExited() if (matchStatistics != null) { - int oldLength = matchStatistics.LengthInSeconds; int newLength = matchStatistics.LengthInSeconds + (int)(DateTime.Now - gameLoadTime).TotalSeconds; - matchStatistics.ParseStatistics(ProgramConstants.GamePath, - ClientConfiguration.Instance.LocalGame, true); + await matchStatistics.ParseStatisticsAsync(ProgramConstants.GamePath, true).ConfigureAwait(false); matchStatistics.LengthInSeconds = newLength; - StatisticsManager.Instance.SaveDatabase(); + await StatisticsManager.Instance.SaveDatabaseAsync().ConfigureAwait(false); } + UpdateDiscordPresence(true); } @@ -397,9 +401,8 @@ public void Refresh(bool isHost) ddSavedGame.AllowDropDown = isHost; btnLoadGame.Text = isHost ? "Load Game".L10N("Client:Main:ButtonLoadGame") : "I'm Ready".L10N("Client:Main:ButtonGetReady"); - IniFile spawnSGIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "Saved Games", "spawnSG.ini")); + IniFile spawnSGIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI)); - loadedGameID = spawnSGIni.GetStringValue("Settings", "GameID", "0"); lblMapNameValue.Tag = spawnSGIni.GetStringValue("Settings", "UIMapName", string.Empty); lblMapNameValue.Text = ((string)lblGameModeValue.Tag).L10N($"INI:Maps:{spawnSGIni.GetStringValue("Settings", "MapID", string.Empty)}:Description"); lblGameModeValue.Tag = spawnSGIni.GetStringValue("Settings", "UIGameMode", string.Empty); @@ -475,9 +478,7 @@ protected void CopyPlayerDataToUI() } } - protected virtual string GetIPAddressForPlayer(PlayerInfo pInfo) => "0.0.0.0"; - - private void DdSavedGame_SelectedIndexChanged(object sender, EventArgs e) + private async ValueTask DdSavedGame_SelectedIndexChangedAsync() { if (!IsHost) return; @@ -488,16 +489,16 @@ private void DdSavedGame_SelectedIndexChanged(object sender, EventArgs e) CopyPlayerDataToUI(); if (!isSettingUp) - BroadcastOptions(); + await BroadcastOptionsAsync().ConfigureAwait(false); UpdateDiscordPresence(); } - private void TbChatInput_EnterPressed(object sender, EventArgs e) + private async ValueTask TbChatInput_EnterPressedAsync() { if (string.IsNullOrEmpty(tbChatInput.Text)) return; - SendChatMessage(tbChatInput.Text); + await SendChatMessageAsync(tbChatInput.Text).ConfigureAwait(false); tbChatInput.Text = string.Empty; } @@ -505,9 +506,9 @@ private void TbChatInput_EnterPressed(object sender, EventArgs e) /// Override in a derived class to broadcast player ready statuses and the selected /// saved game to players. /// - protected abstract void BroadcastOptions(); + protected abstract ValueTask BroadcastOptionsAsync(); - protected abstract void SendChatMessage(string message); + protected abstract ValueTask SendChatMessageAsync(string message); public override void Draw(GameTime gameTime) { @@ -523,4 +524,4 @@ public override void Draw(GameTime gameTime) public abstract string GetSwitchName(); } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index bb6371b9f..a28f0c60d 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -1,8 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using ClientCore; using ClientCore.CnCNet5; +using ClientCore.Extensions; using ClientGUI; -using DTAClient.Domain.Multiplayer; using DTAClient.Domain; +using DTAClient.Domain.Multiplayer; +using DTAClient.Domain.Multiplayer.CnCNet; using DTAClient.DXGUI.Generic; using DTAClient.DXGUI.Multiplayer.CnCNet; using DTAClient.DXGUI.Multiplayer.GameLobby.CommandHandlers; @@ -12,1926 +23,2417 @@ using Rampastring.Tools; using Rampastring.XNAUI; using Rampastring.XNAUI.XNAControls; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using DTAClient.Domain.Multiplayer.CnCNet; -using ClientCore.Extensions; -namespace DTAClient.DXGUI.Multiplayer.GameLobby -{ - public class CnCNetGameLobby : MultiplayerGameLobby - { - private const int HUMAN_PLAYER_OPTIONS_LENGTH = 3; - private const int AI_PLAYER_OPTIONS_LENGTH = 2; - - private const double GAME_BROADCAST_INTERVAL = 30.0; - private const double GAME_BROADCAST_ACCELERATION = 10.0; - private const double INITIAL_GAME_BROADCAST_DELAY = 10.0; - - private static readonly Color ERROR_MESSAGE_COLOR = Color.Yellow; - - private const string MAP_SHARING_FAIL_MESSAGE = "MAPFAIL"; - private const string MAP_SHARING_DOWNLOAD_REQUEST = "MAPOK"; - private const string MAP_SHARING_UPLOAD_REQUEST = "MAPREQ"; - private const string MAP_SHARING_DISABLED_MESSAGE = "MAPSDISABLED"; - private const string CHEAT_DETECTED_MESSAGE = "CD"; - private const string DICE_ROLL_MESSAGE = "DR"; - private const string CHANGE_TUNNEL_SERVER_MESSAGE = "CHTNL"; - - public CnCNetGameLobby( - WindowManager windowManager, - TopBar topBar, - CnCNetManager connectionManager, - TunnelHandler tunnelHandler, - GameCollection gameCollection, - CnCNetUserData cncnetUserData, - MapLoader mapLoader, - DiscordHandler discordHandler - ) : base(windowManager, "MultiplayerGameLobby", topBar, mapLoader, discordHandler) - { - this.connectionManager = connectionManager; - localGame = ClientConfiguration.Instance.LocalGame; - this.tunnelHandler = tunnelHandler; - this.gameCollection = gameCollection; - this.cncnetUserData = cncnetUserData; - this.pmWindow = pmWindow; - - ctcpCommandHandlers = new CommandHandlerBase[] - { - new IntCommandHandler("OR", HandleOptionsRequest), - new IntCommandHandler("R", HandleReadyRequest), - new StringCommandHandler("PO", ApplyPlayerOptions), - new StringCommandHandler(PlayerExtraOptions.CNCNET_MESSAGE_KEY, ApplyPlayerExtraOptions), - new StringCommandHandler("GO", ApplyGameOptions), - new StringCommandHandler("START", NonHostLaunchGame), - new NotificationHandler("AISPECS", HandleNotification, AISpectatorsNotification), - new NotificationHandler("GETREADY", HandleNotification, GetReadyNotification), - new NotificationHandler("INSFSPLRS", HandleNotification, InsufficientPlayersNotification), - new NotificationHandler("TMPLRS", HandleNotification, TooManyPlayersNotification), - new NotificationHandler("CLRS", HandleNotification, SharedColorsNotification), - new NotificationHandler("SLOC", HandleNotification, SharedStartingLocationNotification), - new NotificationHandler("LCKGME", HandleNotification, LockGameNotification), - new IntNotificationHandler("NVRFY", HandleIntNotification, NotVerifiedNotification), - new IntNotificationHandler("INGM", HandleIntNotification, StillInGameNotification), - new StringCommandHandler(MAP_SHARING_UPLOAD_REQUEST, HandleMapUploadRequest), - new StringCommandHandler(MAP_SHARING_FAIL_MESSAGE, HandleMapTransferFailMessage), - new StringCommandHandler(MAP_SHARING_DOWNLOAD_REQUEST, HandleMapDownloadRequest), - new NoParamCommandHandler(MAP_SHARING_DISABLED_MESSAGE, HandleMapSharingBlockedMessage), - new NoParamCommandHandler("RETURN", ReturnNotification), - new IntCommandHandler("TNLPNG", HandleTunnelPing), - new StringCommandHandler("FHSH", FileHashNotification), - new StringCommandHandler("MM", CheaterNotification), - new StringCommandHandler(DICE_ROLL_MESSAGE, HandleDiceRollResult), - new NoParamCommandHandler(CHEAT_DETECTED_MESSAGE, HandleCheatDetectedMessage), - new StringCommandHandler(CHANGE_TUNNEL_SERVER_MESSAGE, HandleTunnelServerChangeMessage) - }; - - MapSharer.MapDownloadFailed += MapSharer_MapDownloadFailed; - MapSharer.MapDownloadComplete += MapSharer_MapDownloadComplete; - MapSharer.MapUploadFailed += MapSharer_MapUploadFailed; - MapSharer.MapUploadComplete += MapSharer_MapUploadComplete; +namespace DTAClient.DXGUI.Multiplayer.GameLobby; - AddChatBoxCommand(new ChatBoxCommand("TUNNELINFO", - "View tunnel server information".L10N("Client:Main:TunnelInfoCommand"), false, PrintTunnelServerInformation)); - AddChatBoxCommand(new ChatBoxCommand("CHANGETUNNEL", - "Change the used CnCNet tunnel server (game host only)".L10N("Client:Main:ChangeTunnelCommand"), - true, (s) => ShowTunnelSelectionWindow("Select tunnel server:".L10N("Client:Main:SelectTunnelServerCommand")))); - AddChatBoxCommand(new ChatBoxCommand("DOWNLOADMAP", - "Download a map from CNCNet's map server using a map ID and an optional filename.\nExample: \"/downloadmap MAPID [2] My Battle Map\"".L10N("Client:Main:DownloadMapCommandDescription"), - false, DownloadMapByIdCommand)); - } +internal sealed class CnCNetGameLobby : MultiplayerGameLobby +{ + private const int HUMAN_PLAYER_OPTIONS_LENGTH = 3; + private const int AI_PLAYER_OPTIONS_LENGTH = 2; + private const double GAME_BROADCAST_INTERVAL = 30.0; + private const double GAME_BROADCAST_ACCELERATION = 10.0; + private const double INITIAL_GAME_BROADCAST_DELAY = 10.0; + private const double MAX_TIME_FOR_GAME_LAUNCH = 20.0; + private const int PRIORITY_START_GAME = 10; + + private static readonly Color ERROR_MESSAGE_COLOR = Color.Yellow; + + private readonly TunnelHandler tunnelHandler; + private readonly CnCNetManager connectionManager; + private readonly string localGame; + private readonly List ctcpCommandHandlers; + private readonly GameCollection gameCollection; + private readonly CnCNetUserData cncnetUserData; + private readonly PrivateMessagingWindow pmWindow; + private readonly List gamePlayerIds = new(); + private readonly List hostUploadedMaps = new(); + private readonly List chatCommandDownloadedMaps = new(); + + private TunnelSelectionWindow tunnelSelectionWindow; + private XNAClientButton btnChangeTunnel; + private Channel channel; + private GlobalContextMenu globalContextMenu; + private string hostName; + private IRCColor chatColor; + private XNATimerControl gameBroadcastTimer; + private XNATimerControl gameStartTimer; + private int playerLimit; + private bool closed; + private bool isCustomPassword; + private bool[] isPlayerConnected; + private bool isStartingGame; + private string gameFilesHash; + private MapSharingConfirmationPanel mapSharingConfirmationPanel; + private CancellationTokenSource gameStartCancellationTokenSource; + private EventHandler channel_UserAddedFunc; + private EventHandler channel_UserQuitIRCFunc; + private EventHandler channel_UserLeftFunc; + private EventHandler channel_UserKickedFunc; + private EventHandler channel_UserListReceivedFunc; + private EventHandler connectionManager_ConnectionLostFunc; + private EventHandler connectionManager_DisconnectedFunc; + private EventHandler tunnelHandler_CurrentTunnelFunc; + private bool disposed; + private V3ConnectionState v3ConnectionState; + + /// + /// The SHA1 of the latest selected map. + /// Used for map sharing. + /// + private string lastMapHash; + + /// + /// The map name of the latest selected map. + /// Used for map sharing. + /// + private string lastMapName; + + /// + /// Set to true if host has selected invalid tunnel server. + /// + private bool tunnelErrorMode; + + public CnCNetGameLobby( + WindowManager windowManager, + TopBar topBar, + CnCNetManager connectionManager, + TunnelHandler tunnelHandler, + GameCollection gameCollection, + CnCNetUserData cncnetUserData, + MapLoader mapLoader, + DiscordHandler discordHandler, + PrivateMessagingWindow pmWindow) + : base(windowManager, "MultiplayerGameLobby", topBar, mapLoader, discordHandler) + { + this.connectionManager = connectionManager; + this.tunnelHandler = tunnelHandler; + this.gameCollection = gameCollection; + this.cncnetUserData = cncnetUserData; + this.pmWindow = pmWindow; + localGame = ClientConfiguration.Instance.LocalGame; + ctcpCommandHandlers = new() + { + new IntCommandHandler(CnCNetCommands.OPTIONS_REQUEST, (playerName, options) => HandleOptionsRequestAsync(playerName, options).HandleTask()), + new IntCommandHandler(CnCNetCommands.READY_REQUEST, (playerName, options) => HandleReadyRequestAsync(playerName, options).HandleTask()), + new StringCommandHandler(CnCNetCommands.PLAYER_OPTIONS, ApplyPlayerOptions), + new StringCommandHandler(CnCNetCommands.PLAYER_EXTRA_OPTIONS, ApplyPlayerExtraOptions), + new StringCommandHandler(CnCNetCommands.GAME_OPTIONS, (playerName, message) => ApplyGameOptionsAsync(playerName, message).HandleTask()), + new StringCommandHandler(CnCNetCommands.GAME_START_V2, (playerName, message) => ClientLaunchGameV2Async(playerName, message).HandleTask()), + new StringCommandHandler(CnCNetCommands.GAME_START_V3, ClientLaunchGameV3), + new NoParamCommandHandler(CnCNetCommands.TUNNEL_CONNECTION_OK, playerName => HandlePlayerConnectedToTunnelAsync(playerName).HandleTask()), + new NoParamCommandHandler(CnCNetCommands.TUNNEL_CONNECTION_FAIL, playerName => HandleTunnelFailAsync(playerName).HandleTask()), + new NotificationHandler(CnCNetCommands.AI_SPECTATORS, HandleNotification, () => AISpectatorsNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.GET_READY_LOBBY, HandleNotification, () => GetReadyNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.INSUFFICIENT_PLAYERS, HandleNotification, () => InsufficientPlayersNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.TOO_MANY_PLAYERS, HandleNotification, () => TooManyPlayersNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.SHARED_COLORS, HandleNotification, () => SharedColorsNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.SHARED_STARTING_LOCATIONS, HandleNotification, () => SharedStartingLocationNotificationAsync().HandleTask()), + new NotificationHandler(CnCNetCommands.LOCK_GAME, HandleNotification, () => LockGameNotificationAsync().HandleTask()), + new IntNotificationHandler(CnCNetCommands.NOT_VERIFIED, HandleIntNotification, playerIndex => NotVerifiedNotificationAsync(playerIndex).HandleTask()), + new IntNotificationHandler(CnCNetCommands.STILL_IN_GAME, HandleIntNotification, playerIndex => StillInGameNotificationAsync(playerIndex).HandleTask()), + new StringCommandHandler(CnCNetCommands.MAP_SHARING_UPLOAD, HandleMapUploadRequest), + new StringCommandHandler(CnCNetCommands.MAP_SHARING_FAIL, HandleMapTransferFailMessage), + new StringCommandHandler(CnCNetCommands.MAP_SHARING_DOWNLOAD, HandleMapDownloadRequest), + new NoParamCommandHandler(CnCNetCommands.MAP_SHARING_DISABLED, HandleMapSharingBlockedMessage), + new NoParamCommandHandler(CnCNetCommands.RETURN, ReturnNotification), + new StringCommandHandler(CnCNetCommands.FILE_HASH, (playerName, filesHash) => FileHashNotificationAsync(playerName, filesHash).HandleTask()), + new StringCommandHandler(CnCNetCommands.CHEATER, CheaterNotification), + new StringCommandHandler(CnCNetCommands.DICE_ROLL, HandleDiceRollResult), + new NoParamCommandHandler(CnCNetCommands.CHEAT_DETECTED, HandleCheatDetectedMessage), + new IntCommandHandler(CnCNetCommands.TUNNEL_PING, HandleTunnelPing), + new StringCommandHandler(CnCNetCommands.CHANGE_TUNNEL_SERVER, (playerName, hash) => HandleTunnelServerChangeMessageAsync(playerName, hash).HandleTask()), + new StringCommandHandler(CnCNetCommands.PLAYER_TUNNEL_PINGS, HandleTunnelPingsMessage), + new StringCommandHandler(CnCNetCommands.PLAYER_P2P_REQUEST, (playerName, p2pRequestMessage) => HandleP2PRequestMessageAsync(playerName, p2pRequestMessage, true).HandleTask()), + new StringCommandHandler(CnCNetCommands.PLAYER_P2P_PINGS, (playerName, p2pPingsMessage) => HandleP2PPingsMessageAsync(playerName, p2pPingsMessage).HandleTask()) + }; + + MapSharer.MapDownloadFailed += (_, e) => WindowManager.AddCallback(() => MapSharer_HandleMapDownloadFailedAsync(e).HandleTask()); + MapSharer.MapDownloadComplete += (_, e) => WindowManager.AddCallback(() => MapSharer_HandleMapDownloadCompleteAsync(e).HandleTask()); + MapSharer.MapUploadFailed += (_, e) => WindowManager.AddCallback(() => MapSharer_HandleMapUploadFailedAsync(e).HandleTask()); + MapSharer.MapUploadComplete += (_, e) => WindowManager.AddCallback(() => MapSharer_HandleMapUploadCompleteAsync(e).HandleTask()); + WindowManager.GameClosing += (_, _) => Dispose(true); + + AddChatBoxCommand(new( + CnCNetLobbyCommands.TUNNELINFO, + "View tunnel server information".L10N("Client:Main:TunnelInfo"), + false, + PrintTunnelServerInformation)); + AddChatBoxCommand(new( + CnCNetLobbyCommands.CHANGETUNNEL, + "Change the used CnCNet tunnel server (game host only)".L10N("Client:Main:ChangeTunnel"), + true, + _ => ShowTunnelSelectionWindow("Select tunnel server:".L10N("Client:Main:SelectTunnelServer")))); + AddChatBoxCommand(new( + CnCNetLobbyCommands.DOWNLOADMAP, + "Download a map from CNCNet's map server using a map ID and an optional filename.\nExample: \"/downloadmap MAPID [2] My Battle Map\"".L10N("Client:Main:DownloadMapCommandDescription"), + false, + DownloadMapByIdCommand)); + AddChatBoxCommand(new( + CnCNetLobbyCommands.DYNAMICTUNNELS, + "Toggle dynamic CnCNet tunnel servers on/off (game host only)".L10N("Client:Main:ChangeDynamicTunnels"), + true, + _ => ToggleDynamicTunnelsAsync().HandleTask())); + AddChatBoxCommand(new( + CnCNetLobbyCommands.P2P, + "Toggle P2P connections on/off, your IP will be public to players in the lobby".L10N("Client:Main:ChangeP2P"), + false, + _ => ToggleP2PAsync().HandleTask())); +#if DEBUG + AddChatBoxCommand(new( + CnCNetLobbyCommands.RECORD, + "Toggle recording game replay".L10N("Client:Main:ChangeRecord"), + false, + _ => ToggleRecordAsync().HandleTask())); + AddChatBoxCommand(new( + CnCNetLobbyCommands.REPLAY, + "Start a game replay.\nExample: \"/replay REPLAYID".L10N("Client:Main:StartReplay"), + true, + StartReplay)); +#endif + } - public event EventHandler GameLeft; + public event EventHandler GameLeft; - private TunnelHandler tunnelHandler; - private TunnelSelectionWindow tunnelSelectionWindow; - private XNAClientButton btnChangeTunnel; + public override void Initialize() + { + IniNameOverride = nameof(CnCNetGameLobby); - private Channel channel; - private CnCNetManager connectionManager; - private string localGame; + base.Initialize(); - private GameCollection gameCollection; - private CnCNetUserData cncnetUserData; - private readonly PrivateMessagingWindow pmWindow; - private GlobalContextMenu globalContextMenu; + btnChangeTunnel = FindChild(nameof(btnChangeTunnel)); - private string hostName; + btnChangeTunnel.LeftClick += BtnChangeTunnel_LeftClick; - private CommandHandlerBase[] ctcpCommandHandlers; + gameBroadcastTimer = new(WindowManager) + { + AutoReset = true, + Interval = TimeSpan.FromSeconds(GAME_BROADCAST_INTERVAL), + Enabled = false + }; + gameBroadcastTimer.TimeElapsed += (_, _) => BroadcastGameAsync().HandleTask(); - private IRCColor chatColor; + gameStartTimer = new(WindowManager) + { + AutoReset = false, + Interval = TimeSpan.FromSeconds(MAX_TIME_FOR_GAME_LAUNCH) + }; + gameStartTimer.TimeElapsed += GameStartTimer_TimeElapsed; - private XNATimerControl gameBroadcastTimer; + tunnelSelectionWindow = new(WindowManager, tunnelHandler); - private int playerLimit; + tunnelSelectionWindow.Initialize(); - private bool closed = false; + tunnelSelectionWindow.DrawOrder = 1; + tunnelSelectionWindow.UpdateOrder = 1; - private bool isCustomPassword = false; + DarkeningPanel.AddAndInitializeWithControl(WindowManager, tunnelSelectionWindow); + tunnelSelectionWindow.CenterOnParent(); + tunnelSelectionWindow.Disable(); - private string gameFilesHash; + tunnelSelectionWindow.TunnelSelected += (_, e) => TunnelSelectionWindow_TunnelSelectedAsync(e).HandleTask(); - private List hostUploadedMaps = new List(); - private List chatCommandDownloadedMaps = new List(); + mapSharingConfirmationPanel = new(WindowManager); - private MapSharingConfirmationPanel mapSharingConfirmationPanel; + MapPreviewBox.AddChild(mapSharingConfirmationPanel); - /// - /// The SHA1 of the latest selected map. - /// Used for map sharing. - /// - private string lastMapSHA1; + mapSharingConfirmationPanel.MapDownloadConfirmed += MapSharingConfirmationPanel_MapDownloadConfirmed; - /// - /// The map name of the latest selected map. - /// Used for map sharing. - /// - private string lastMapName; + WindowManager.AddAndInitializeControl(gameBroadcastTimer); - /// - /// The game mode of the latest selected map. - /// Used for map sharing. - /// - private string lastGameMode; + globalContextMenu = new(WindowManager, connectionManager, cncnetUserData, pmWindow); - /// - /// Set to true if host has selected invalid tunnel server. - /// - private bool tunnelErrorMode; + AddChild(globalContextMenu); + AddChild(gameStartTimer); - public override void Initialize() - { - IniNameOverride = nameof(CnCNetGameLobby); - base.Initialize(); + MultiplayerNameRightClicked += MultiplayerName_RightClick; - btnChangeTunnel = FindChild(nameof(btnChangeTunnel)); - btnChangeTunnel.LeftClick += BtnChangeTunnel_LeftClick; + channel_UserAddedFunc = (_, e) => Channel_UserAddedAsync(e).HandleTask(); + channel_UserQuitIRCFunc = (_, e) => ChannelUserLeftAsync(e).HandleTask(); + channel_UserLeftFunc = (_, e) => ChannelUserLeftAsync(e).HandleTask(); + channel_UserKickedFunc = (_, e) => Channel_UserKickedAsync(e).HandleTask(); + channel_UserListReceivedFunc = (_, _) => Channel_UserListReceivedAsync().HandleTask(); + connectionManager_ConnectionLostFunc = (_, _) => HandleConnectionLossAsync().HandleTask(); + connectionManager_DisconnectedFunc = (_, _) => HandleConnectionLossAsync().HandleTask(); + tunnelHandler_CurrentTunnelFunc = (_, _) => UpdatePingAsync().HandleTask(); - gameBroadcastTimer = new XNATimerControl(WindowManager); - gameBroadcastTimer.AutoReset = true; - gameBroadcastTimer.Interval = TimeSpan.FromSeconds(GAME_BROADCAST_INTERVAL); - gameBroadcastTimer.Enabled = false; - gameBroadcastTimer.TimeElapsed += GameBroadcastTimer_TimeElapsed; + v3ConnectionState = new(tunnelHandler); - tunnelSelectionWindow = new TunnelSelectionWindow(WindowManager, tunnelHandler); - tunnelSelectionWindow.Initialize(); - tunnelSelectionWindow.DrawOrder = 1; - tunnelSelectionWindow.UpdateOrder = 1; - DarkeningPanel.AddAndInitializeWithControl(WindowManager, tunnelSelectionWindow); - tunnelSelectionWindow.CenterOnParent(); - tunnelSelectionWindow.Disable(); - tunnelSelectionWindow.TunnelSelected += TunnelSelectionWindow_TunnelSelected; + PostInitialize(); + } - mapSharingConfirmationPanel = new MapSharingConfirmationPanel(WindowManager); - MapPreviewBox.AddChild(mapSharingConfirmationPanel); - mapSharingConfirmationPanel.MapDownloadConfirmed += MapSharingConfirmationPanel_MapDownloadConfirmed; + protected override void Dispose(bool disposing) + { + if (!disposed) + { + if (disposing) + ClearAsync(true).HandleTask(); - WindowManager.AddAndInitializeControl(gameBroadcastTimer); + disposed = true; + } - globalContextMenu = new GlobalContextMenu(WindowManager, connectionManager, cncnetUserData, pmWindow); - AddChild(globalContextMenu); + base.Dispose(disposing); + } - MultiplayerNameRightClicked += MultiplayerName_RightClick; + private void GameStartTimer_TimeElapsed(object sender, EventArgs e) + { + string playerString = string.Empty; - PostInitialize(); + for (int i = 0; i < Players.Count; i++) + { + if (!isPlayerConnected[i]) + { + if (string.IsNullOrWhiteSpace(playerString)) + playerString = Players[i].Name; + else + playerString += ", " + Players[i].Name; + } } - private void MultiplayerName_RightClick(object sender, MultiplayerNameRightClickedEventArgs args) - { - globalContextMenu.Show(new GlobalContextMenuData() + AddNotice(string.Format(CultureInfo.InvariantCulture, "Some players ({0}) failed to connect within the time limit. Aborting game launch.", playerString)); + AbortGameStartAsync().HandleTask(); + } + + private void MultiplayerName_RightClick(object sender, MultiplayerNameRightClickedEventArgs args) + { + globalContextMenu.Show( + new GlobalContextMenuData { PlayerName = args.PlayerName, PreventJoinGame = true - }, GetCursorPoint()); - } + }, + GetCursorPoint()); + } + + private void BtnChangeTunnel_LeftClick(object sender, EventArgs e) + => ShowTunnelSelectionWindow("Select tunnel server:".L10N("Client:Main:SelectTunnelServer")); - private void BtnChangeTunnel_LeftClick(object sender, EventArgs e) => ShowTunnelSelectionWindow("Select tunnel server:".L10N("Client:Main:SelectTunnelServer")); + public async ValueTask SetUpAsync( + Channel channel, + bool isHost, + int playerLimit, + CnCNetTunnel tunnel, + string hostName, + bool isCustomPassword) + { + this.channel = channel; + this.hostName = hostName; + this.playerLimit = playerLimit; + this.isCustomPassword = isCustomPassword; + v3ConnectionState.DynamicTunnelsEnabled = UserINISettings.Instance.UseDynamicTunnels; + v3ConnectionState.P2PEnabled = UserINISettings.Instance.UseP2P; + v3ConnectionState.RecordingEnabled = UserINISettings.Instance.EnableReplays; + channel.MessageAdded += Channel_MessageAdded; + channel.CTCPReceived += Channel_CTCPReceived; + channel.UserKicked += channel_UserKickedFunc; + channel.UserQuitIRC += channel_UserQuitIRCFunc; + channel.UserLeft += channel_UserLeftFunc; + channel.UserAdded += channel_UserAddedFunc; + channel.UserNameChanged += Channel_UserNameChanged; + channel.UserListReceived += channel_UserListReceivedFunc; - private void GameBroadcastTimer_TimeElapsed(object sender, EventArgs e) => BroadcastGame(); + if (isHost) + { + RandomSeed = new Random().Next(); - public void SetUp(Channel channel, bool isHost, int playerLimit, - CnCNetTunnel tunnel, string hostName, bool isCustomPassword) + await RefreshMapSelectionUIAsync().ConfigureAwait(false); + btnChangeTunnel.Enable(); + } + else { - this.channel = channel; - channel.MessageAdded += Channel_MessageAdded; - channel.CTCPReceived += Channel_CTCPReceived; - channel.UserKicked += Channel_UserKicked; - channel.UserQuitIRC += Channel_UserQuitIRC; - channel.UserLeft += Channel_UserLeft; - channel.UserAdded += Channel_UserAdded; - channel.UserNameChanged += Channel_UserNameChanged; - channel.UserListReceived += Channel_UserListReceived; + channel.ChannelModesChanged += Channel_ChannelModesChanged; - this.hostName = hostName; - this.playerLimit = playerLimit; - this.isCustomPassword = isCustomPassword; + AIPlayers.Clear(); + } - if (isHost) - { - RandomSeed = new Random().Next(); - RefreshMapSelectionUI(); - btnChangeTunnel.Enable(); - } - else - { - channel.ChannelModesChanged += Channel_ChannelModesChanged; - AIPlayers.Clear(); - btnChangeTunnel.Disable(); - } + v3ConnectionState.Setup(tunnel); - tunnelHandler.CurrentTunnel = tunnel; - tunnelHandler.CurrentTunnelPinged += TunnelHandler_CurrentTunnelPinged; + tunnelHandler.CurrentTunnelPinged += tunnelHandler_CurrentTunnelFunc; + connectionManager.ConnectionLost += connectionManager_ConnectionLostFunc; + connectionManager.Disconnected += connectionManager_DisconnectedFunc; - connectionManager.ConnectionLost += ConnectionManager_ConnectionLost; - connectionManager.Disconnected += ConnectionManager_Disconnected; + Refresh(isHost); + } - Refresh(isHost); - } + public async ValueTask OnJoinedAsync() + { + var fhc = new FileHashCalculator(); + + fhc.CalculateHashes(GameModeMaps.GameModes); - private void TunnelHandler_CurrentTunnelPinged(object sender, EventArgs e) => UpdatePing(); + gameFilesHash = fhc.GetCompleteHash(); - public void OnJoined() + v3ConnectionState.PinTunnels(); + + if (IsHost) { - FileHashCalculator fhc = new FileHashCalculator(); - fhc.CalculateHashes(GameModeMaps.GameModes); + await connectionManager.SendCustomMessageAsync(new( + FormattableString.Invariant($"{IRCCommands.MODE} {channel.ChannelName} +{IRCChannelModes.DEFAULT} {channel.Password} {playerLimit}"), + QueuedMessageType.SYSTEM_MESSAGE, + 50)).ConfigureAwait(false); - gameFilesHash = fhc.GetCompleteHash(); + await connectionManager.SendCustomMessageAsync(new( + FormattableString.Invariant($"{IRCCommands.TOPIC} {channel.ChannelName} :{ProgramConstants.CNCNET_PROTOCOL_REVISION}:{localGame.ToLower()}"), + QueuedMessageType.SYSTEM_MESSAGE, + 50)).ConfigureAwait(false); - if (IsHost) - { - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("MODE {0} +klnNs {1} {2}", channel.ChannelName, - channel.Password, playerLimit), - QueuedMessageType.SYSTEM_MESSAGE, 50)); - - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("TOPIC {0} :{1}", channel.ChannelName, - ProgramConstants.CNCNET_PROTOCOL_REVISION + ";" + localGame.ToLower()), - QueuedMessageType.SYSTEM_MESSAGE, 50)); - - gameBroadcastTimer.Enabled = true; - gameBroadcastTimer.Start(); - gameBroadcastTimer.SetTime(TimeSpan.FromSeconds(INITIAL_GAME_BROADCAST_DELAY)); - } - else - { - channel.SendCTCPMessage("FHSH " + gameFilesHash, QueuedMessageType.SYSTEM_MESSAGE, 10); - } + gameBroadcastTimer.Enabled = true; - TopBar.AddPrimarySwitchable(this); - TopBar.SwitchToPrimary(); - WindowManager.SelectedControl = tbChatInput; - ResetAutoReadyCheckbox(); - UpdatePing(); - UpdateDiscordPresence(true); + gameBroadcastTimer.Start(); + gameBroadcastTimer.SetTime(TimeSpan.FromSeconds(INITIAL_GAME_BROADCAST_DELAY)); } - - private void UpdatePing() + else { - if (tunnelHandler.CurrentTunnel == null) - return; + await channel.SendCTCPMessageAsync(CnCNetCommands.FILE_HASH + " " + gameFilesHash, QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); - channel.SendCTCPMessage("TNLPNG " + tunnelHandler.CurrentTunnel.PingInMs, QueuedMessageType.SYSTEM_MESSAGE, 10); + if (v3ConnectionState.DynamicTunnelsEnabled) + BroadcastPlayerTunnelPingsAsync().HandleTask(); - PlayerInfo pInfo = Players.Find(p => p.Name.Equals(ProgramConstants.PLAYERNAME)); - if (pInfo != null) - { - pInfo.Ping = tunnelHandler.CurrentTunnel.PingInMs; - UpdatePlayerPingIndicator(pInfo); - } + if (v3ConnectionState.P2PEnabled) + BroadcastPlayerP2PRequestAsync().HandleTask(); } - protected override void CopyPlayerDataToUI() + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + WindowManager.SelectedControl = tbChatInput; + ResetAutoReadyCheckbox(); + await UpdatePingAsync().ConfigureAwait(false); + UpdateDiscordPresence(true); + } + + private async ValueTask UpdatePingAsync() + { + int ping; + + if (v3ConnectionState.DynamicTunnelsEnabled && v3ConnectionState.PinnedTunnels.Any()) + ping = v3ConnectionState.PinnedTunnels.Min(q => q.Ping); + else if (tunnelHandler.CurrentTunnel == null) + return; + else + ping = tunnelHandler.CurrentTunnel.PingInMs; + + await channel.SendCTCPMessageAsync(CnCNetCommands.TUNNEL_PING + " " + ping, QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); + + PlayerInfo pInfo = FindLocalPlayer(); + + if (pInfo != null) { - base.CopyPlayerDataToUI(); + pInfo.Ping = ping; - for (int i = AIPlayers.Count + Players.Count; i < MAX_PLAYER_COUNT; i++) - { - StatusIndicators[i].SwitchTexture( - i < playerLimit ? PlayerSlotState.Empty : PlayerSlotState.Unavailable); - } + UpdatePlayerPingIndicator(pInfo); } + } - private void PrintTunnelServerInformation(string s) + protected override void CopyPlayerDataToUI() + { + base.CopyPlayerDataToUI(); + + for (int i = AIPlayers.Count + Players.Count; i < MAX_PLAYER_COUNT; i++) { - if (tunnelHandler.CurrentTunnel == null) - { - AddNotice("Tunnel server unavailable!".L10N("Client:Main:TunnelUnavailable")); - } - else - { - AddNotice(string.Format("Current tunnel server: {0} {1} (Players: {2}/{3}) (Official: {4})".L10N("Client:Main:TunnelInfo"), - tunnelHandler.CurrentTunnel.Name, tunnelHandler.CurrentTunnel.Country, tunnelHandler.CurrentTunnel.Clients, tunnelHandler.CurrentTunnel.MaxClients, tunnelHandler.CurrentTunnel.Official - )); - } + StatusIndicators[i].SwitchTexture( + i < playerLimit ? PlayerSlotState.Empty : PlayerSlotState.Unavailable); } + } - private void ShowTunnelSelectionWindow(string description) + private void PrintTunnelServerInformation(string s) + { + if (v3ConnectionState.DynamicTunnelsEnabled) { - tunnelSelectionWindow.Open(description, - tunnelHandler.CurrentTunnel?.Address); + AddNotice("Dynamic tunnels enabled".L10N("Client:Main:DynamicTunnelsEnabled")); } - - private void TunnelSelectionWindow_TunnelSelected(object sender, TunnelEventArgs e) + else if (tunnelHandler.CurrentTunnel is null) { - channel.SendCTCPMessage($"{CHANGE_TUNNEL_SERVER_MESSAGE} {e.Tunnel.Address}:{e.Tunnel.Port}", - QueuedMessageType.SYSTEM_MESSAGE, 10); - HandleTunnelServerChange(e.Tunnel); + AddNotice("Tunnel server unavailable!".L10N("Client:Main:TunnelUnavailable")); } - - public void ChangeChatColor(IRCColor chatColor) + else { - this.chatColor = chatColor; - tbChatInput.TextColor = chatColor.XnaColor; + AddNotice(string.Format(CultureInfo.CurrentCulture, + "Current tunnel server: {0} {1} (Players: {2}/{3}) (Official: {4})".L10N("Client:Main:TunnelInfo"), + tunnelHandler.CurrentTunnel.Name, + tunnelHandler.CurrentTunnel.Country, + tunnelHandler.CurrentTunnel.Clients, + tunnelHandler.CurrentTunnel.MaxClients, + tunnelHandler.CurrentTunnel.Official)); } + } - public override void Clear() - { - base.Clear(); + private void ShowTunnelSelectionWindow(string description) + => tunnelSelectionWindow.Open(description, tunnelHandler.CurrentTunnel); - if (channel != null) - { - channel.MessageAdded -= Channel_MessageAdded; - channel.CTCPReceived -= Channel_CTCPReceived; - channel.UserKicked -= Channel_UserKicked; - channel.UserQuitIRC -= Channel_UserQuitIRC; - channel.UserLeft -= Channel_UserLeft; - channel.UserAdded -= Channel_UserAdded; - channel.UserNameChanged -= Channel_UserNameChanged; - channel.UserListReceived -= Channel_UserListReceived; - - if (!IsHost) - { - channel.ChannelModesChanged -= Channel_ChannelModesChanged; - } + private async ValueTask TunnelSelectionWindow_TunnelSelectedAsync(TunnelEventArgs e) + { + await channel.SendCTCPMessageAsync( + $"{CnCNetCommands.CHANGE_TUNNEL_SERVER} {e.Tunnel.Hash}", + QueuedMessageType.SYSTEM_MESSAGE, + 10).ConfigureAwait(false); + await HandleTunnelServerChangeAsync(e.Tunnel).ConfigureAwait(false); + } - connectionManager.RemoveChannel(channel); - } + public void ChangeChatColor(IRCColor chatColor) + { + this.chatColor = chatColor; + tbChatInput.TextColor = chatColor.XnaColor; + } + + public override async ValueTask ClearAsync(bool exiting) + { + await base.ClearAsync(exiting).ConfigureAwait(false); + + if (channel != null) + { + channel.MessageAdded -= Channel_MessageAdded; + channel.CTCPReceived -= Channel_CTCPReceived; + channel.UserKicked -= channel_UserKickedFunc; + channel.UserQuitIRC -= channel_UserQuitIRCFunc; + channel.UserLeft -= channel_UserLeftFunc; + channel.UserAdded -= channel_UserAddedFunc; + channel.UserNameChanged -= Channel_UserNameChanged; + channel.UserListReceived -= channel_UserListReceivedFunc; - Disable(); - connectionManager.ConnectionLost -= ConnectionManager_ConnectionLost; - connectionManager.Disconnected -= ConnectionManager_Disconnected; + if (!IsHost) + channel.ChannelModesChanged -= Channel_ChannelModesChanged; - gameBroadcastTimer.Enabled = false; - closed = false; + connectionManager.RemoveChannel(channel); + } - tbChatInput.Text = string.Empty; + Disable(); - tunnelHandler.CurrentTunnel = null; - tunnelHandler.CurrentTunnelPinged -= TunnelHandler_CurrentTunnelPinged; + connectionManager.ConnectionLost -= connectionManager_ConnectionLostFunc; + connectionManager.Disconnected -= connectionManager_DisconnectedFunc; + gameBroadcastTimer.Enabled = false; + closed = false; + tbChatInput.Text = string.Empty; + tunnelHandler.CurrentTunnelPinged -= tunnelHandler_CurrentTunnelFunc; + tunnelHandler.CurrentTunnel = null; - GameLeft?.Invoke(this, EventArgs.Empty); + gameStartCancellationTokenSource?.Cancel(); + v3ConnectionState.DisposeAsync().HandleTask(); + gamePlayerIds.Clear(); + if (!exiting) + { + GameLeft?.Invoke(this, EventArgs.Empty); TopBar.RemovePrimarySwitchable(this); ResetDiscordPresence(); } + } - public void LeaveGameLobby() + public async ValueTask LeaveGameLobbyAsync() + { + if (IsHost) { - if (IsHost) - { - closed = true; - BroadcastGame(); - } - - Clear(); - channel.Leave(); + closed = true; + await BroadcastGameAsync().ConfigureAwait(false); } - private void ConnectionManager_Disconnected(object sender, EventArgs e) => HandleConnectionLoss(); + await ClearAsync(false).ConfigureAwait(false); + await channel.LeaveAsync().ConfigureAwait(false); + } - private void ConnectionManager_ConnectionLost(object sender, ConnectionLostEventArgs e) => HandleConnectionLoss(); + private async ValueTask HandleConnectionLossAsync() + { + await ClearAsync(false).ConfigureAwait(false); + Disable(); + } - private void HandleConnectionLoss() - { - Clear(); - Disable(); - } + private void Channel_UserNameChanged(object sender, UserNameChangedEventArgs e) + { + Logger.Log("CnCNetGameLobby: Nickname change: " + e.OldUserName + " to " + e.User.Name); + + int index = Players.FindIndex(p => p.Name.Equals(e.OldUserName, StringComparison.OrdinalIgnoreCase)); - private void Channel_UserNameChanged(object sender, UserNameChangedEventArgs e) + if (index > -1) { - Logger.Log("CnCNetGameLobby: Nickname change: " + e.OldUserName + " to " + e.User.Name); - int index = Players.FindIndex(p => p.Name == e.OldUserName); - if (index > -1) - { - PlayerInfo player = Players[index]; - player.Name = e.User.Name; - ddPlayerNames[index].Items[0].Text = player.Name; - AddNotice(string.Format("Player {0} changed their name to {1}".L10N("Client:Main:PlayerRename"), e.OldUserName, e.User.Name)); - } + PlayerInfo player = Players[index]; + + player.Name = e.User.Name; + ddPlayerNames[index].Items[0].Text = player.Name; + + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} changed their name to {1}".L10N("Client:Main:PlayerRename"), e.OldUserName, e.User.Name)); } + } - protected override void BtnLeaveGame_LeftClick(object sender, EventArgs e) => LeaveGameLobby(); + protected override ValueTask BtnLeaveGame_LeftClickAsync() + => LeaveGameLobbyAsync(); - protected override void UpdateDiscordPresence(bool resetTimer = false) - { - if (discordHandler == null) - return; + protected override void UpdateDiscordPresence(bool resetTimer = false) + { + if (discordHandler == null) + return; + + PlayerInfo player = FindLocalPlayer(); + + if (player == null || Map == null || GameMode == null) + return; + string side = string.Empty; + + if (ddPlayerSides.Length > Players.IndexOf(player)) + side = (string)ddPlayerSides[Players.IndexOf(player)].SelectedItem.Tag; + + string currentState = ProgramConstants.IsInGame ? "In Game" : "In Lobby"; // not UI strings + + discordHandler.UpdatePresence( + Map.UntranslatedName, + GameMode.UntranslatedUIName, + "Multiplayer", + currentState, + Players.Count, + playerLimit, + side, + channel.UIName, + IsHost, + isCustomPassword, + Locked, + resetTimer); + } - PlayerInfo player = FindLocalPlayer(); - if (player == null || Map == null || GameMode == null) - return; - string side = ""; - if (ddPlayerSides.Length > Players.IndexOf(player)) - side = (string)ddPlayerSides[Players.IndexOf(player)].SelectedItem.Tag; - string currentState = ProgramConstants.IsInGame ? "In Game" : "In Lobby"; // not UI strings + private async ValueTask ChannelUserLeftAsync(UserNameEventArgs e) + { + await RemovePlayerAsync(e.UserName).ConfigureAwait(false); - discordHandler.UpdatePresence( - Map.UntranslatedName, GameMode.UntranslatedUIName, "Multiplayer", - currentState, Players.Count, playerLimit, side, - channel.UIName, IsHost, isCustomPassword, Locked, resetTimer); + if (e.UserName.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + { + connectionManager.MainChannel.AddMessage( + new(ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); + await BtnLeaveGame_LeftClickAsync().ConfigureAwait(false); } - - private void Channel_UserQuitIRC(object sender, UserNameEventArgs e) + else { - RemovePlayer(e.UserName); - - if (e.UserName == hostName) - { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } - else - UpdateDiscordPresence(); + UpdateDiscordPresence(); } + } - private void Channel_UserLeft(object sender, UserNameEventArgs e) + private async ValueTask Channel_UserKickedAsync(UserNameEventArgs e) + { + if (e.UserName.Equals(ProgramConstants.PLAYERNAME, StringComparison.OrdinalIgnoreCase)) { - RemovePlayer(e.UserName); + connectionManager.MainChannel.AddMessage( + new(ERROR_MESSAGE_COLOR, "You were kicked from the game!".L10N("Client:Main:YouWereKicked"))); + await ClearAsync(false).ConfigureAwait(false); - if (e.UserName == hostName) - { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } - else - UpdateDiscordPresence(); + Visible = false; + Enabled = false; + return; } - private void Channel_UserKicked(object sender, UserNameEventArgs e) + Players.Remove(Players.SingleOrDefault(p => p.Name.Equals(e.UserName, StringComparison.OrdinalIgnoreCase))); + CopyPlayerDataToUI(); + UpdateDiscordPresence(); + ClearReadyStatuses(); + v3ConnectionState.RemoveV3Player(e.UserName); + } + + private async ValueTask Channel_UserListReceivedAsync() + { + if (!IsHost) { - if (e.UserName == ProgramConstants.PLAYERNAME) + if (channel.Users.Find(hostName) is null) { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "You were kicked from the game!".L10N("Client:Main:YouWereKicked"))); - Clear(); - this.Visible = false; - this.Enabled = false; - return; + connectionManager.MainChannel.AddMessage( + new(ERROR_MESSAGE_COLOR, "The game host has abandoned the game.".L10N("Client:Main:HostHasAbandoned"))); + await BtnLeaveGame_LeftClickAsync().ConfigureAwait(false); } + } - int index = Players.FindIndex(p => p.Name == e.UserName); + UpdateDiscordPresence(); + } - if (index > -1) - { - Players.RemoveAt(index); - CopyPlayerDataToUI(); - UpdateDiscordPresence(); - ClearReadyStatuses(); - } + private async ValueTask Channel_UserAddedAsync(ChannelUserEventArgs e) + { + var pInfo = new PlayerInfo(e.User.IRCUser.Name); + + Players.Add(pInfo); + + if (Players.Count + AIPlayers.Count > MAX_PLAYER_COUNT && AIPlayers.Count > 0) + AIPlayers.RemoveAt(AIPlayers.Count - 1); + + if (v3ConnectionState.DynamicTunnelsEnabled && pInfo != FindLocalPlayer()) + BroadcastPlayerTunnelPingsAsync().HandleTask(); + + if (v3ConnectionState.P2PEnabled && pInfo != FindLocalPlayer()) + BroadcastPlayerP2PRequestAsync().HandleTask(); + + sndJoinSound.Play(); +#if WINFORMS + WindowManager.FlashWindow(); +#endif + + if (!IsHost) + { + CopyPlayerDataToUI(); + return; } - private void Channel_UserListReceived(object sender, EventArgs e) + if (e.User.IRCUser.Name != ProgramConstants.PLAYERNAME) { - if (!IsHost) - { - if (channel.Users.Find(hostName) == null) - { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "The game host has abandoned the game.".L10N("Client:Main:HostHasAbandoned"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } - } + // Changing the map applies forced settings (co-op sides etc.) to the + // new player, and it also sends an options broadcast message + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); UpdateDiscordPresence(); } + else + { + Players[0].Ready = true; + CopyPlayerDataToUI(); + } - private void Channel_UserAdded(object sender, ChannelUserEventArgs e) + if (Players.Count >= playerLimit) { - PlayerInfo pInfo = new PlayerInfo(e.User.IRCUser.Name); - Players.Add(pInfo); + AddNotice("Player limit reached. The game room has been locked.".L10N("Client:Main:GameRoomNumberLimitReached")); + await LockGameAsync().ConfigureAwait(false); + } + } - if (Players.Count + AIPlayers.Count > MAX_PLAYER_COUNT && AIPlayers.Count > 0) - AIPlayers.RemoveAt(AIPlayers.Count - 1); + private async ValueTask RemovePlayerAsync(string playerName) + { + await AbortGameStartAsync().ConfigureAwait(false); + Players.Remove(Players.SingleOrDefault(p => p.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase))); + CopyPlayerDataToUI(); + v3ConnectionState.RemoveV3Player(playerName); - sndJoinSound.Play(); -#if WINFORMS - WindowManager.FlashWindow(); -#endif + // This might not be necessary + if (IsHost) + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); - if (!IsHost) - { - CopyPlayerDataToUI(); - return; - } + sndLeaveSound.Play(); - if (e.User.IRCUser.Name != ProgramConstants.PLAYERNAME) - { - // Changing the map applies forced settings (co-op sides etc.) to the - // new player, and it also sends an options broadcast message - //CopyPlayerDataToUI(); This is also called by ChangeMap() - ChangeMap(GameModeMap); - BroadcastPlayerOptions(); - BroadcastPlayerExtraOptions(); - UpdateDiscordPresence(); - } - else - { - Players[0].Ready = true; - CopyPlayerDataToUI(); - } + if (IsHost && Locked && !ProgramConstants.IsInGame) + await UnlockGameAsync(true).ConfigureAwait(false); + } + private void Channel_ChannelModesChanged(object sender, ChannelModeEventArgs e) + { + if (e.ModeString == "+i") + { if (Players.Count >= playerLimit) - { AddNotice("Player limit reached. The game room has been locked.".L10N("Client:Main:GameRoomNumberLimitReached")); - LockGame(); - } + else + AddNotice("The game host has locked the game room.".L10N("Client:Main:RoomLockedByHost")); + Locked = true; } - - private void RemovePlayer(string playerName) + else if (e.ModeString == "-i") { - PlayerInfo pInfo = Players.Find(p => p.Name == playerName); - - if (pInfo != null) - { - Players.Remove(pInfo); - - CopyPlayerDataToUI(); - - // This might not be necessary - if (IsHost) - BroadcastPlayerOptions(); - } + AddNotice("The game room has been unlocked.".L10N("Client:Main:GameRoomUnlocked")); + Locked = false; + } + } - sndLeaveSound.Play(); + private void Channel_CTCPReceived(object sender, ChannelCTCPEventArgs e) + { +#if DEBUG + Logger.Log($"CnCNetGameLobby_CTCPReceived from {e.UserName}: {e.Message}"); +#else + Logger.Log("CnCNetGameLobby_CTCPReceived"); +#endif - if (IsHost && Locked && !ProgramConstants.IsInGame) + foreach (CommandHandlerBase cmdHandler in ctcpCommandHandlers) + { + if (cmdHandler.Handle(e.UserName, e.Message)) { - UnlockGame(true); + UpdateDiscordPresence(); + return; } } - private void Channel_ChannelModesChanged(object sender, ChannelModeEventArgs e) + Logger.Log("Unhandled CTCP command: " + e.Message + " from " + e.UserName); + } + + private void Channel_MessageAdded(object sender, IRCMessageEventArgs e) + { + if (cncnetUserData.IsIgnored(e.Message.SenderIdent)) { - if (e.ModeString == "+i") - { - if (Players.Count >= playerLimit) - AddNotice("Player limit reached. The game room has been locked.".L10N("Client:Main:GameRoomNumberLimitReached")); - else - AddNotice("The game host has locked the game room.".L10N("Client:Main:RoomLockedByHost")); - Locked = true; - } - else if (e.ModeString == "-i") - { - AddNotice("The game room has been unlocked.".L10N("Client:Main:GameRoomUnlocked")); - Locked = false; - } + lbChatMessages.AddMessage(new ChatMessage( + Color.Silver, + string.Format( + CultureInfo.CurrentCulture, + "Message blocked from {0}".L10N("Client:Main:MessageBlockedFromPlayer"), + e.Message.SenderName))); } - - private void Channel_CTCPReceived(object sender, ChannelCTCPEventArgs e) + else { - Logger.Log("CnCNetGameLobby_CTCPReceived"); - - foreach (CommandHandlerBase cmdHandler in ctcpCommandHandlers) - { - if (cmdHandler.Handle(e.UserName, e.Message)) - { - UpdateDiscordPresence(); - return; - } - } + lbChatMessages.AddMessage(e.Message); - Logger.Log("Unhandled CTCP command: " + e.Message + " from " + e.UserName); + if (e.Message.SenderName != null) + sndMessageSound.Play(); } + } - private void Channel_MessageAdded(object sender, IRCMessageEventArgs e) + /// + /// Starts the game for the game host. + /// + protected override async ValueTask HostLaunchGameAsync() + { + if (Players.Count > 1) { - if (cncnetUserData.IsIgnored(e.Message.SenderIdent)) - { - lbChatMessages.AddMessage(new ChatMessage(Color.Silver, - string.Format("Message blocked from {0}".L10N("Client:Main:MessageBlockedFromPlayer"), e.Message.SenderName))); - } + AddNotice("Contacting remote hosts...".L10N("Client:Main:ConnectingTunnel")); + + if (tunnelHandler.CurrentTunnel?.Version == Constants.TUNNEL_VERSION_2) + await HostLaunchGameV2Async().ConfigureAwait(false); + else if (v3ConnectionState.DynamicTunnelsEnabled || tunnelHandler.CurrentTunnel?.Version == Constants.TUNNEL_VERSION_3) + await HostLaunchGameV3Async().ConfigureAwait(false); else - { - lbChatMessages.AddMessage(e.Message); + throw new InvalidOperationException("Unknown tunnel server version!"); - if (e.Message.SenderName != null) - sndMessageSound.Play(); - } + return; } - /// - /// Starts the game for the game host. - /// - protected override void HostLaunchGame() - { - if (Players.Count > 1) - { - AddNotice("Contacting tunnel server...".L10N("Client:Main:ConnectingTunnel")); + Logger.Log("One player MP -- starting!"); + Players.ForEach(pInfo => pInfo.IsInGame = true); + CopyPlayerDataToUI(); + cncnetUserData.AddRecentPlayers(Players.Select(p => p.Name), channel.UIName); - List playerPorts = tunnelHandler.CurrentTunnel.GetPlayerPortInfo(Players.Count); + await StartGameAsync().ConfigureAwait(false); + } - if (playerPorts.Count < Players.Count) - { - ShowTunnelSelectionWindow(("An error occured while contacting " + - "the CnCNet tunnel server.\nTry picking a different tunnel server:").L10N("Client:Main:ConnectTunnelError1")); - AddNotice(("An error occured while contacting the specified CnCNet " + - "tunnel server. Please try using a different tunnel server").L10N("Client:Main:ConnectTunnelError2") + " ", ERROR_MESSAGE_COLOR); - return; - } + private async ValueTask HostLaunchGameV2Async() + { + List playerPorts = await tunnelHandler.CurrentTunnel.GetPlayerPortInfoAsync(Players.Count).ConfigureAwait(false); - StringBuilder sb = new StringBuilder("START "); - sb.Append(UniqueGameID); - for (int pId = 0; pId < Players.Count; pId++) - { - Players[pId].Port = playerPorts[pId]; - sb.Append(";"); - sb.Append(Players[pId].Name); - sb.Append(";"); - sb.Append("0.0.0.0:"); - sb.Append(playerPorts[pId]); - } - channel.SendCTCPMessage(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 10); - } - else - { - Logger.Log("One player MP -- starting!"); - } + if (playerPorts.Count < Players.Count) + { + ShowTunnelSelectionWindow(("An error occured while contacting " + + "the CnCNet tunnel server.\nTry picking a different tunnel server:").L10N("Client:Main:ConnectTunnelError1")); + AddNotice(string.Format(CultureInfo.CurrentCulture, "An error occured while contacting the specified CnCNet " + + "tunnel server. Please try using a different tunnel server " + + "(accessible by typing /{0} in the chat box).".L10N("Client:Main:ConnectTunnelError2"), CnCNetLobbyCommands.CHANGETUNNEL), + ERROR_MESSAGE_COLOR); + return; + } - Players.ForEach(pInfo => pInfo.IsInGame = true); - CopyPlayerDataToUI(); + string playerPortsV2String = SetGamePlayerPortsV2(playerPorts); - cncnetUserData.AddRecentPlayers(Players.Select(p => p.Name), channel.UIName); + await channel.SendCTCPMessageAsync( + $"{CnCNetCommands.GAME_START_V2} {UniqueGameID} {playerPortsV2String}", QueuedMessageType.SYSTEM_MESSAGE, PRIORITY_START_GAME).ConfigureAwait(false); + Players.ForEach(pInfo => pInfo.IsInGame = true); + await StartGameAsync().ConfigureAwait(false); + } - StartGame(); - } + private string SetGamePlayerPortsV2(IReadOnlyList playerPorts) + { + var sb = new StringBuilder(); - protected override void RequestPlayerOptions(int side, int color, int start, int team) + for (int pId = 0; pId < Players.Count; pId++) { - byte[] value = new byte[] - { - (byte)side, - (byte)color, - (byte)start, - (byte)team - }; + Players[pId].Port = playerPorts[pId]; - int intValue = BitConverter.ToInt32(value, 0); - - channel.SendCTCPMessage( - string.Format("OR {0}", intValue), - QueuedMessageType.GAME_SETTINGS_MESSAGE, 6); + sb.Append(';') + .Append(Players[pId].Name) + .Append(';') + .Append($"{IPAddress.Any}:") + .Append(playerPorts[pId]); } - protected override void RequestReadyStatus() - { - if (Map == null || GameMode == null) - { - AddNotice(("The game host needs to select a different map or " + - "you will be unable to participate in the match.").L10N("Client:Main:HostMustReplaceMap")); + return sb.ToString(); + } - if (chkAutoReady.Checked) - channel.SendCTCPMessage("R 0", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 5); + private async ValueTask HostLaunchGameV3Async() + { + btnLaunchGame.InputEnabled = false; - return; - } + string gamePlayerIdsString = HostGenerateGamePlayerIds(); - PlayerInfo pInfo = Players.Find(p => p.Name == ProgramConstants.PLAYERNAME); - int readyState = 0; + await channel.SendCTCPMessageAsync( + $"{CnCNetCommands.GAME_START_V3} {UniqueGameID}{gamePlayerIdsString}", QueuedMessageType.SYSTEM_MESSAGE, PRIORITY_START_GAME).ConfigureAwait(false); - if (chkAutoReady.Checked) - readyState = 2; - else if (!pInfo.Ready) - readyState = 1; + isStartingGame = true; - channel.SendCTCPMessage($"R {readyState}", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 5); - } + StartV3ConnectionListeners(); + } - protected override void AddNotice(string message, Color color) => channel.AddMessage(new ChatMessage(color, message)); + private string HostGenerateGamePlayerIds() + { + var random = new Random(); + uint randomNumber = (uint)random.Next(0, int.MaxValue - (MAX_PLAYER_COUNT / 2)) * (uint)random.Next(1, 3); + var sb = new StringBuilder(); + + gamePlayerIds.Clear(); - /// - /// Handles player option requests received from non-host players. - /// - private void HandleOptionsRequest(string playerName, int options) + for (int i = 0; i < Players.Count; i++) { - if (!IsHost) - return; + uint id = randomNumber + (uint)i; - if (ProgramConstants.IsInGame) - return; + sb.Append(';') + .Append(id); + gamePlayerIds.Add(id); + } - PlayerInfo pInfo = Players.Find(p => p.Name == playerName); + return sb.ToString(); + } - if (pInfo == null) - return; + private void ClientLaunchGameV3(string sender, string message) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - byte[] bytes = BitConverter.GetBytes(options); + string[] parts = message.Split(';'); - int side = bytes[0]; - int color = bytes[1]; - int start = bytes[2]; - int team = bytes[3]; + if (parts.Length != Players.Count + 1) + return; - if (side < 0 || side > SideCount + RandomSelectorCount) - return; + UniqueGameID = Conversions.IntFromString(parts[0], -1); - if (color < 0 || color > MPColors.Count) - return; + if (UniqueGameID < 0) + return; - var disallowedSides = GetDisallowedSides(); + gamePlayerIds.Clear(); - if (side > 0 && side <= SideCount && disallowedSides[side - 1]) + for (int i = 1; i < parts.Length; i++) + { + if (!uint.TryParse(parts[i], out uint id)) return; - if (Map.CoopInfo != null) - { - if (Map.CoopInfo.DisallowedPlayerSides.Contains(side - 1) || side == SideCount + RandomSelectorCount) - return; - - if (Map.CoopInfo.DisallowedPlayerColors.Contains(color - 1)) - return; - } - - if (start < 0 || start > Map.MaxPlayers) - return; + gamePlayerIds.Add(id); + } - if (team < 0 || team > 4) - return; + isStartingGame = true; - if (side != pInfo.SideId - || start != pInfo.StartingLocation - || team != pInfo.TeamId) - { - ClearReadyStatuses(); - } + StartV3ConnectionListeners(); + } - pInfo.SideId = side; - pInfo.ColorId = color; - pInfo.StartingLocation = start; - pInfo.TeamId = team; + private void StartV3ConnectionListeners() + { + isPlayerConnected = new bool[Players.Count]; - CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - } + uint gameLocalPlayerId = gamePlayerIds[Players.FindIndex(p => p == FindLocalPlayer())]; - /// - /// Handles "I'm ready" messages received from non-host players. - /// - private void HandleReadyRequest(string playerName, int readyStatus) - { - if (!IsHost) - return; + gameStartCancellationTokenSource?.Dispose(); - PlayerInfo pInfo = Players.Find(p => p.Name == playerName); + gameStartCancellationTokenSource = new(); - if (pInfo == null) - return; + void RemoteHostConnectedAction() => AddCallback(() => GameTunnelHandler_Connected_CallbackAsync().HandleTask()); + void RemoteHostConnectionFailedAction() => AddCallback(() => GameTunnelHandler_ConnectionFailed_CallbackAsync().HandleTask()); - pInfo.Ready = readyStatus > 0; - pInfo.AutoReady = readyStatus > 1; + v3ConnectionState.StartV3ConnectionListeners( + UniqueGameID, + gameLocalPlayerId, + FindLocalPlayer().Name, + Players, + RemoteHostConnectedAction, + RemoteHostConnectionFailedAction, + gameStartCancellationTokenSource.Token); - CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - } + // Abort starting the game if not everyone + // replies within the timer's limit + gameStartTimer.Start(); + } - /// - /// Broadcasts player options to non-host players. - /// - protected override void BroadcastPlayerOptions() + private async ValueTask GameTunnelHandler_Connected_CallbackAsync() + { + if (v3ConnectionState.DynamicTunnelsEnabled) { - // Broadcast player options - StringBuilder sb = new StringBuilder("PO "); - foreach (PlayerInfo pInfo in Players.Concat(AIPlayers)) - { - if (pInfo.IsAI) - sb.Append(pInfo.AILevel); - else - sb.Append(pInfo.Name); - sb.Append(";"); - - // Combine the options into one integer to save bandwidth in - // cases where the player uses default options (this is common for AI players) - // Will hopefully make GameSurge kicking people a bit less common - byte[] byteArray = new byte[] - { - (byte)pInfo.TeamId, - (byte)pInfo.StartingLocation, - (byte)pInfo.ColorId, - (byte)pInfo.SideId, - }; - - int value = BitConverter.ToInt32(byteArray, 0); - sb.Append(value); - sb.Append(";"); - if (!pInfo.IsAI) - { - if (pInfo.AutoReady && !pInfo.IsInGame) - sb.Append(2); - else - sb.Append(Convert.ToInt32(pInfo.Ready)); - sb.Append(';'); - } - } - - channel.SendCTCPMessage(sb.ToString(), QueuedMessageType.GAME_PLAYERS_MESSAGE, 11); + if (v3ConnectionState.V3GameTunnelHandlers.Any() && v3ConnectionState.V3GameTunnelHandlers.TrueForAll(q => q.Tunnel.ConnectSucceeded)) + await SetLocalPlayerConnectedAsync().ConfigureAwait(false); } - - protected override void PlayerExtraOptions_OptionsChanged(object sender, EventArgs e) + else { - base.PlayerExtraOptions_OptionsChanged(sender, e); - BroadcastPlayerExtraOptions(); + await SetLocalPlayerConnectedAsync().ConfigureAwait(false); } - protected override void BroadcastPlayerExtraOptions() - { - if (!IsHost) - return; - - var playerExtraOptions = GetPlayerExtraOptions(); - - channel.SendCTCPMessage(playerExtraOptions.ToCncnetMessage(), QueuedMessageType.GAME_PLAYERS_EXTRA_MESSAGE, 11, true); - } + await channel.SendCTCPMessageAsync(CnCNetCommands.TUNNEL_CONNECTION_OK, QueuedMessageType.SYSTEM_MESSAGE, PRIORITY_START_GAME).ConfigureAwait(false); + } - /// - /// Handles player option messages received from the game host. - /// - private void ApplyPlayerOptions(string sender, string message) - { - if (sender != hostName) - return; + private ValueTask SetLocalPlayerConnectedAsync() + => HandlePlayerConnectedToTunnelAsync(FindLocalPlayer().Name); - Players.Clear(); - AIPlayers.Clear(); + private async ValueTask GameTunnelHandler_ConnectionFailed_CallbackAsync() + { + await channel.SendCTCPMessageAsync(CnCNetCommands.TUNNEL_CONNECTION_FAIL, QueuedMessageType.INSTANT_MESSAGE, 0).ConfigureAwait(false); + await HandleTunnelFailAsync(ProgramConstants.PLAYERNAME).ConfigureAwait(false); + } - string[] parts = message.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length;) - { - PlayerInfo pInfo = new PlayerInfo(); + private async ValueTask HandleTunnelFailAsync(string playerName) + { + Logger.Log(playerName + " failed to connect - aborting game launch."); + AddNotice(string.Format(CultureInfo.CurrentCulture, "{0} failed to connect. Please retry, disable P2P or pick " + + "another tunnel server by typing /{1} in the chat input box.".L10N("Client:Main:PlayerConnectFailed"), playerName, CnCNetLobbyCommands.CHANGETUNNEL)); + await AbortGameStartAsync().ConfigureAwait(false); + } - string pName = parts[i]; - int converted = Conversions.IntFromString(pName, -1); + private async ValueTask HandlePlayerConnectedToTunnelAsync(string playerName) + { + if (!isStartingGame) + return; - if (converted > -1) - { - pInfo.IsAI = true; - pInfo.AILevel = converted; - pInfo.Name = AILevelToName(converted); - } - else - { - pInfo.Name = pName; - - // If we can't find the player from the channel user list, - // ignore the player - // They've either left the channel or got kicked before the - // player options message reached us - if (channel.Users.Find(pName) == null) - { - i += HUMAN_PLAYER_OPTIONS_LENGTH; - continue; - } - } + int index = Players.FindIndex(p => p.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase)); - if (parts.Length <= i + 1) - return; + if (index == -1) + { + Logger.Log("HandleTunnelConnected: Couldn't find player " + playerName + "!"); + await AbortGameStartAsync().ConfigureAwait(false); + return; + } - int playerOptions = Conversions.IntFromString(parts[i + 1], -1); - if (playerOptions == -1) - return; + isPlayerConnected[index] = true; - byte[] byteArray = BitConverter.GetBytes(playerOptions); + if (isPlayerConnected.All(b => b)) + await LaunchGameV3Async().ConfigureAwait(false); + } - int team = byteArray[0]; - int start = byteArray[1]; - int color = byteArray[2]; - int side = byteArray[3]; + private async ValueTask LaunchGameV3Async() + { + Logger.Log("All players are connected, starting game!"); + AddNotice("All players have connected...".L10N("Client:Main:PlayersConnected")); - if (side < 0 || side > SideCount + RandomSelectorCount) - return; + List usedPorts = v3ConnectionState.StartPlayerConnections(gamePlayerIds); + int gamePort = NetworkHelper.GetFreeUdpPorts(usedPorts, 1).Single(); - if (color < 0 || color > MPColors.Count) - return; + FindLocalPlayer().Port = gamePort; - if (start < 0 || start > MAX_PLAYER_COUNT) - return; + gameStartTimer.Pause(); + v3ConnectionState.StunCancellationTokenSource?.Cancel(); - if (team < 0 || team > 4) - return; + btnLaunchGame.InputEnabled = true; - pInfo.TeamId = byteArray[0]; - pInfo.StartingLocation = byteArray[1]; - pInfo.ColorId = byteArray[2]; - pInfo.SideId = byteArray[3]; + await StartGameAsync().ConfigureAwait(false); + } - if (pInfo.IsAI) - { - pInfo.Ready = true; - AIPlayers.Add(pInfo); - i += AI_PLAYER_OPTIONS_LENGTH; - } - else - { - if (parts.Length <= i + 2) - return; + private async ValueTask AbortGameStartAsync() + { + btnLaunchGame.InputEnabled = true; - int readyStatus = Conversions.IntFromString(parts[i + 2], -1); + gameStartCancellationTokenSource?.Cancel(); + await v3ConnectionState.ClearConnectionsAsync().ConfigureAwait(false); - if (readyStatus == -1) - return; + gameStartTimer.Pause(); - pInfo.Ready = readyStatus > 0; - pInfo.AutoReady = readyStatus > 1; + isStartingGame = false; + } - if (pInfo.Name == ProgramConstants.PLAYERNAME) - btnLaunchGame.Text = pInfo.Ready ? BTN_LAUNCH_NOT_READY : BTN_LAUNCH_READY; + protected override IPAddress GetIPAddressForPlayer(PlayerInfo player) + { + if (v3ConnectionState.P2PEnabled || v3ConnectionState.DynamicTunnelsEnabled || tunnelHandler.CurrentTunnel.Version == Constants.TUNNEL_VERSION_3) + return IPAddress.Loopback.MapToIPv4(); - Players.Add(pInfo); - i += HUMAN_PLAYER_OPTIONS_LENGTH; - } - } + return base.GetIPAddressForPlayer(player); + } - CopyPlayerDataToUI(); - } + protected override ValueTask RequestPlayerOptionsAsync(int side, int color, int start, int team) + { + byte[] value = + { + (byte)side, + (byte)color, + (byte)start, + (byte)team + }; + int intValue = BitConverter.ToInt32(value, 0); + + return channel.SendCTCPMessageAsync( + FormattableString.Invariant($"{CnCNetCommands.OPTIONS_REQUEST} {intValue}"), + QueuedMessageType.GAME_SETTINGS_MESSAGE, + 6); + } - /// - /// Broadcasts game options to non-host players - /// when the host has changed an option. - /// - protected override void OnGameOptionChanged() + protected override async ValueTask RequestReadyStatusAsync() + { + if (Map == null || GameMode == null) { - base.OnGameOptionChanged(); + AddNotice(("The game host needs to select a different map or " + + "you will be unable to participate in the match.").L10N("Client:Main:HostMustReplaceMap")); - if (!IsHost) - return; + if (chkAutoReady.Checked) + await channel.SendCTCPMessageAsync( + CnCNetCommands.READY_REQUEST + " 0", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 5).ConfigureAwait(false); - bool[] optionValues = new bool[CheckBoxes.Count]; - for (int i = 0; i < CheckBoxes.Count; i++) - optionValues[i] = CheckBoxes[i].Checked; + return; + } - // Let's pack the booleans into bytes - List byteList = Conversions.BoolArrayIntoBytes(optionValues).ToList(); + PlayerInfo pInfo = FindLocalPlayer(); + int readyState = 0; - while (byteList.Count % 4 != 0) - byteList.Add(0); + if (chkAutoReady.Checked) + readyState = 2; + else if (!pInfo.Ready) + readyState = 1; - int integerCount = byteList.Count / 4; - byte[] byteArray = byteList.ToArray(); + await channel.SendCTCPMessageAsync( + $"{CnCNetCommands.READY_REQUEST} {readyState}", QueuedMessageType.GAME_PLAYERS_READY_STATUS_MESSAGE, 5).ConfigureAwait(false); + } - ExtendedStringBuilder sb = new ExtendedStringBuilder("GO ", true, ';'); + protected override void AddNotice(string message, Color color) => channel.AddMessage(new(color, message)); - for (int i = 0; i < integerCount; i++) - sb.Append(BitConverter.ToInt32(byteArray, i * 4)); + /// + /// Handles player option requests received from non-host players. + /// + private async ValueTask HandleOptionsRequestAsync(string playerName, int options) + { + if (!IsHost) + return; - // We don't gain much in most cases by packing the drop-down values - // (because they're bytes to begin with, and usually non-zero), - // so let's just transfer them as usual + if (ProgramConstants.IsInGame) + return; - foreach (GameLobbyDropDown dd in DropDowns) - sb.Append(dd.SelectedIndex); + PlayerInfo pInfo = Players.Find(p => p.Name == playerName); - sb.Append(Convert.ToInt32(Map.Official)); - sb.Append(Map.SHA1); - sb.Append(GameMode.Name); - sb.Append(FrameSendRate); - sb.Append(MaxAhead); - sb.Append(ProtocolVersion); - sb.Append(RandomSeed); - sb.Append(Convert.ToInt32(RemoveStartingLocations)); - sb.Append(Map.UntranslatedName); + if (pInfo == null) + return; - channel.SendCTCPMessage(sb.ToString(), QueuedMessageType.GAME_SETTINGS_MESSAGE, 11); - } + byte[] bytes = BitConverter.GetBytes(options); + int side = bytes[0]; + int color = bytes[1]; + int start = bytes[2]; + int team = bytes[3]; - /// - /// Handles game option messages received from the game host. - /// - private void ApplyGameOptions(string sender, string message) - { - if (sender != hostName) - return; + if (side > SideCount + RandomSelectorCount) + return; - string[] parts = message.Split(';'); + if (color > MPColors.Count) + return; - int checkBoxIntegerCount = (CheckBoxes.Count / 32) + 1; + bool[] disallowedSides = GetDisallowedSides(); - int partIndex = checkBoxIntegerCount + DropDowns.Count; + if (side > 0 && side <= SideCount && disallowedSides[side - 1]) + return; - if (parts.Length < partIndex + 6) - { - AddNotice(("The game host has sent an invalid game options message! " + - "The game host's game version might be different from yours.").L10N("Client:Main:HostGameOptionInvalid"), Color.Red); + if (Map.CoopInfo != null) + { + if (Map.CoopInfo.DisallowedPlayerSides.Contains(side - 1) || side == SideCount + RandomSelectorCount) return; - } - string mapOfficial = parts[partIndex]; - bool isMapOfficial = Conversions.BooleanFromString(mapOfficial, true); + if (Map.CoopInfo.DisallowedPlayerColors.Contains(color - 1)) + return; + } - string mapSHA1 = parts[partIndex + 1]; + if (start > Map.MaxPlayers) + return; - string gameMode = parts[partIndex + 2]; + if (team > 4) + return; - int frameSendRate = Conversions.IntFromString(parts[partIndex + 3], FrameSendRate); - if (frameSendRate != FrameSendRate) - { - FrameSendRate = frameSendRate; - AddNotice(string.Format("The game host has changed FrameSendRate (order lag) to {0}".L10N("Client:Main:HostChangeFrameSendRate"), frameSendRate)); - } + if (side != pInfo.SideId + || start != pInfo.StartingLocation + || team != pInfo.TeamId) + { + ClearReadyStatuses(); + } - int maxAhead = Conversions.IntFromString(parts[partIndex + 4], MaxAhead); - if (maxAhead != MaxAhead) - { - MaxAhead = maxAhead; - AddNotice(string.Format("The game host has changed MaxAhead to {0}".L10N("Client:Main:HostChangeMaxAhead"), maxAhead)); - } + pInfo.SideId = side; + pInfo.ColorId = color; + pInfo.StartingLocation = start; + pInfo.TeamId = team; - int protocolVersion = Conversions.IntFromString(parts[partIndex + 5], ProtocolVersion); - if (protocolVersion != ProtocolVersion) - { - ProtocolVersion = protocolVersion; - AddNotice(string.Format("The game host has changed ProtocolVersion to {0}".L10N("Client:Main:HostChangeProtocolVersion"), protocolVersion)); - } + CopyPlayerDataToUI(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + } - string mapName = parts[partIndex + 8]; - GameModeMap currentGameModeMap = GameModeMap; + /// + /// Handles "I'm ready" messages received from non-host players. + /// + private async ValueTask HandleReadyRequestAsync(string playerName, int readyStatus) + { + if (!IsHost) + return; - lastGameMode = gameMode; - lastMapSHA1 = mapSHA1; - lastMapName = mapName; + PlayerInfo pInfo = Players.Find(p => p.Name == playerName); - GameModeMap = GameModeMaps.Find(gmm => gmm.GameMode.Name == gameMode && gmm.Map.SHA1 == mapSHA1); - if (GameModeMap == null) - { - ChangeMap(null); + if (pInfo == null) + return; - if (!isMapOfficial) - RequestMap(mapSHA1); - else - ShowOfficialMapMissingMessage(mapSHA1); - } - else if (GameModeMap != currentGameModeMap) - { - ChangeMap(GameModeMap); - } + pInfo.Ready = readyStatus > 0; + pInfo.AutoReady = readyStatus > 1; - // By changing the game options after changing the map, we know which - // game options were changed by the map and which were changed by the game host + CopyPlayerDataToUI(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + } - // If the map doesn't exist on the local installation, it's impossible - // to know which options were set by the host and which were set by the - // map, so we'll just assume that the host has set all the options. - // Very few (if any) custom maps force options, so it'll be correct nearly always + /// + /// Broadcasts player options to non-host players. + /// + protected override ValueTask BroadcastPlayerOptionsAsync() + { + // Broadcast player options + var sb = new StringBuilder(CnCNetCommands.PLAYER_OPTIONS + " "); - for (int i = 0; i < checkBoxIntegerCount; i++) - { - if (parts.Length <= i) - return; + foreach (PlayerInfo pInfo in Players.Concat(AIPlayers)) + { + if (pInfo.IsAI) + sb.Append(pInfo.AILevel); + else + sb.Append(pInfo.Name); - int checkBoxStatusInt; - bool success = int.TryParse(parts[i], out checkBoxStatusInt); + sb.Append(';'); - if (!success) - { - AddNotice(("Failed to parse check box options sent by game host!" + - "The game host's game version might be different from yours.").L10N("Client:Main:HostCheckBoxParseError"), Color.Red); - return; - } + // Combine the options into one integer to save bandwidth in + // cases where the player uses default options (this is common for AI players) + // Will hopefully make GameSurge kicking people a bit less common + byte[] byteArray = new[] + { + (byte)pInfo.TeamId, + (byte)pInfo.StartingLocation, + (byte)pInfo.ColorId, + (byte)pInfo.SideId, + }; + int value = BitConverter.ToInt32(byteArray, 0); - byte[] byteArray = BitConverter.GetBytes(checkBoxStatusInt); - bool[] boolArray = Conversions.BytesIntoBoolArray(byteArray); + sb.Append(value); + sb.Append(';'); - for (int optionIndex = 0; optionIndex < boolArray.Length; optionIndex++) - { - int gameOptionIndex = i * 32 + optionIndex; + if (!pInfo.IsAI) + { + if (pInfo.AutoReady && !pInfo.IsInGame) + sb.Append(2); + else + sb.Append(Convert.ToInt32(pInfo.Ready)); - if (gameOptionIndex >= CheckBoxes.Count) - break; + sb.Append(';'); + } + } - GameLobbyCheckBox checkBox = CheckBoxes[gameOptionIndex]; + return channel.SendCTCPMessageAsync(sb.ToString(), QueuedMessageType.GAME_PLAYERS_MESSAGE, 11); + } - if (checkBox.Checked != boolArray[optionIndex]) - { - if (boolArray[optionIndex]) - AddNotice(string.Format("The game host has enabled {0}".L10N("Client:Main:HostEnableOption"), checkBox.Text)); - else - AddNotice(string.Format("The game host has disabled {0}".L10N("Client:Main:HostDisableOption"), checkBox.Text)); - } + protected override async ValueTask PlayerExtraOptions_OptionsChangedAsync() + { + await base.PlayerExtraOptions_OptionsChangedAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); + } - CheckBoxes[gameOptionIndex].Checked = boolArray[optionIndex]; - } - } + protected override async ValueTask BroadcastPlayerExtraOptionsAsync() + { + if (!IsHost) + return; - for (int i = checkBoxIntegerCount; i < DropDowns.Count + checkBoxIntegerCount; i++) - { - if (parts.Length <= i) - { - AddNotice(("The game host has sent an invalid game options message! " + - "The game host's game version might be different from yours.").L10N("Client:Main:HostGameOptionInvalid"), Color.Red); - return; - } + PlayerExtraOptions playerExtraOptions = GetPlayerExtraOptions(); - int ddSelectedIndex; - bool success = int.TryParse(parts[i], out ddSelectedIndex); + await channel.SendCTCPMessageAsync( + playerExtraOptions.ToCncnetMessage(), QueuedMessageType.GAME_PLAYERS_EXTRA_MESSAGE, 11, true).ConfigureAwait(false); + } - if (!success) - { - AddNotice(("Failed to parse drop down options sent by game host (2)! " + - "The game host's game version might be different from yours.").L10N("Client:Main:HostDropDownParseError"), Color.Red); - return; - } + private ValueTask BroadcastPlayerTunnelPingsAsync() + => channel.SendCTCPMessageAsync(CnCNetCommands.PLAYER_TUNNEL_PINGS + " " + v3ConnectionState.PinnedTunnelPingsMessage, QueuedMessageType.SYSTEM_MESSAGE, 10); - GameLobbyDropDown dd = DropDowns[i - checkBoxIntegerCount]; + private async ValueTask BroadcastPlayerP2PRequestAsync() + { + bool p2pSetupSucceeded = false; - if (ddSelectedIndex < -1 || ddSelectedIndex >= dd.Items.Count) - continue; + try + { + p2pSetupSucceeded = await v3ConnectionState.HandlePlayerP2PRequestAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, "P2P: setup failed."); + } - if (dd.SelectedIndex != ddSelectedIndex) - { - string ddName = dd.OptionName; - if (dd.OptionName == null) - ddName = dd.Name; + if (!p2pSetupSucceeded) + { + AddNotice(string.Format( + CultureInfo.CurrentCulture, + "P2P: setup failed. Check that UPnP port mapping is enabled for this device on your router/modem.".L10N("Client:Main:P2PSetupFailed")), + Color.Orange); - AddNotice(string.Format("The game host has set {0} to {1}".L10N("Client:Main:HostSetOption"), ddName, dd.Items[ddSelectedIndex].Text)); - } + return; + } - DropDowns[i - checkBoxIntegerCount].SelectedIndex = ddSelectedIndex; - } + await SendPlayerP2PRequestAsync().ConfigureAwait(false); + } - int randomSeed; - bool parseSuccess = int.TryParse(parts[partIndex + 6], out randomSeed); + private ValueTask SendPlayerP2PRequestAsync() + => channel.SendCTCPMessageAsync(CnCNetCommands.PLAYER_P2P_REQUEST + v3ConnectionState.GetP2PRequestCommand(), QueuedMessageType.SYSTEM_MESSAGE, 10); - if (!parseSuccess) - { - AddNotice(("Failed to parse random seed from game options message! " + - "The game host's game version might be different from yours.").L10N("Client:Main:HostRandomSeedError"), Color.Red); - } + /// + /// Handles player option messages received from the game host. + /// + private void ApplyPlayerOptions(string sender, string message) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - bool removeStartingLocations = Convert.ToBoolean(Conversions.IntFromString(parts[partIndex + 7], - Convert.ToInt32(RemoveStartingLocations))); - SetRandomStartingLocations(removeStartingLocations); + Players.Clear(); + AIPlayers.Clear(); - RandomSeed = randomSeed; - } + string[] parts = message.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); - private void RequestMap(string mapSHA1) + for (int i = 0; i < parts.Length;) { - if (UserINISettings.Instance.EnableMapSharing) + var pInfo = new PlayerInfo(); + string pName = parts[i]; + int converted = Conversions.IntFromString(pName, -1); + + if (converted > -1) { - AddNotice("The game host has selected a map that doesn't exist on your installation.".L10N("Client:Main:MapNotExist")); - mapSharingConfirmationPanel.ShowForMapDownload(); + pInfo.IsAI = true; + pInfo.AILevel = converted; + pInfo.Name = AILevelToName(converted); } else { - AddNotice("The game host has selected a map that doesn't exist on your installation.".L10N("Client:Main:MapNotExist") + " " + - ("Because you've disabled map sharing, it cannot be transferred. The game host needs " + - "to change the map or you will be unable to participate in the match.").L10N("Client:Main:MapSharingDisabledNotice")); - channel.SendCTCPMessage(MAP_SHARING_DISABLED_MESSAGE, QueuedMessageType.SYSTEM_MESSAGE, 9); - } - } - - private void ShowOfficialMapMissingMessage(string sha1) - { - AddNotice(("The game host has selected an official map that doesn't exist on your installation. " + - "This could mean that the game host has modified game files, or is running a different game version. " + - "They need to change the map or you will be unable to participate in the match.").L10N("Client:Main:OfficialMapNotExist")); - channel.SendCTCPMessage(MAP_SHARING_FAIL_MESSAGE + " " + sha1, QueuedMessageType.SYSTEM_MESSAGE, 9); - } + pInfo.Name = pName; - private void MapSharingConfirmationPanel_MapDownloadConfirmed(object sender, EventArgs e) - { - Logger.Log("Map sharing confirmed."); - AddNotice("Attempting to download map.".L10N("Client:Main:DownloadingMap")); - mapSharingConfirmationPanel.SetDownloadingStatus(); - MapSharer.DownloadMap(lastMapSHA1, localGame, lastMapName); - } + // If we can't find the player from the channel user list, + // ignore the player + // They've either left the channel or got kicked before the + // player options message reached us + if (channel.Users.Find(pName) == null) + { + i += HUMAN_PLAYER_OPTIONS_LENGTH; + continue; + } + } - protected override void ChangeMap(GameModeMap gameModeMap) - { - mapSharingConfirmationPanel.Disable(); - base.ChangeMap(gameModeMap); - } + if (parts.Length <= i + 1) + return; - /// - /// Signals other players that the local player has returned from the game, - /// and unlocks the game as well as generates a new random seed as the game host. - /// - protected override void GameProcessExited() - { - base.GameProcessExited(); + int playerOptions = Conversions.IntFromString(parts[i + 1], -1); - channel.SendCTCPMessage("RETURN", QueuedMessageType.SYSTEM_MESSAGE, 20); - ReturnNotification(ProgramConstants.PLAYERNAME); + if (playerOptions == -1) + return; - if (IsHost) - { - RandomSeed = new Random().Next(); - OnGameOptionChanged(); - ClearReadyStatuses(); - CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - BroadcastPlayerExtraOptions(); - - if (Players.Count < playerLimit) - UnlockGame(true); - } - } + byte[] byteArray = BitConverter.GetBytes(playerOptions); + int team = byteArray[0]; + int start = byteArray[1]; + int color = byteArray[2]; + int side = byteArray[3]; - /// - /// Handles the "START" (game start) command sent by the game host. - /// - private void NonHostLaunchGame(string sender, string message) - { - if (sender != hostName) + if (side > SideCount + RandomSelectorCount) return; - string[] parts = message.Split(';'); + if (color > MPColors.Count) + return; - if (parts.Length < 1) + if (start > MAX_PLAYER_COUNT) return; - UniqueGameID = Conversions.IntFromString(parts[0], -1); - if (UniqueGameID < 0) + if (team > 4) return; - var recentPlayers = new List(); + pInfo.TeamId = byteArray[0]; + pInfo.StartingLocation = byteArray[1]; + pInfo.ColorId = byteArray[2]; + pInfo.SideId = byteArray[3]; - for (int i = 1; i < parts.Length; i += 2) + if (pInfo.IsAI) { - if (parts.Length <= i + 1) - return; + pInfo.Ready = true; - string pName = parts[i]; - string[] ipAndPort = parts[i + 1].Split(':'); + AIPlayers.Add(pInfo); - if (ipAndPort.Length < 2) + i += AI_PLAYER_OPTIONS_LENGTH; + } + else + { + if (parts.Length <= i + 2) return; - int port; - bool success = int.TryParse(ipAndPort[1], out port); + int readyStatus = Conversions.IntFromString(parts[i + 2], -1); - if (!success) + if (readyStatus == -1) return; - PlayerInfo pInfo = Players.Find(p => p.Name == pName); + pInfo.Ready = readyStatus > 0; + pInfo.AutoReady = readyStatus > 1; - if (pInfo == null) - return; + if (pInfo == FindLocalPlayer()) + btnLaunchGame.Text = pInfo.Ready ? BTN_LAUNCH_NOT_READY : BTN_LAUNCH_READY; - pInfo.Port = port; - recentPlayers.Add(pName); - } - cncnetUserData.AddRecentPlayers(recentPlayers, channel.UIName); + Players.Add(pInfo); - StartGame(); + i += HUMAN_PLAYER_OPTIONS_LENGTH; + } } - protected override void StartGame() - { - AddNotice("Starting game...".L10N("Client:Main:StartingGame")); + CopyPlayerDataToUI(); + } - FileHashCalculator fhc = new FileHashCalculator(); - fhc.CalculateHashes(GameModeMaps.GameModes); + /// + /// Broadcasts game options to non-host players + /// when the host has changed an option. + /// + protected override async ValueTask OnGameOptionChangedAsync() + { + await base.OnGameOptionChangedAsync().ConfigureAwait(false); - if (gameFilesHash != fhc.GetCompleteHash()) - { - Logger.Log("Game files modified during client session!"); - channel.SendCTCPMessage(CHEAT_DETECTED_MESSAGE, QueuedMessageType.INSTANT_MESSAGE, 0); - HandleCheatDetectedMessage(ProgramConstants.PLAYERNAME); - } + if (!IsHost) + return; - base.StartGame(); - } + bool[] optionValues = new bool[CheckBoxes.Count]; - protected override void WriteSpawnIniAdditions(IniFile iniFile) - { - base.WriteSpawnIniAdditions(iniFile); + for (int i = 0; i < CheckBoxes.Count; i++) + optionValues[i] = CheckBoxes[i].Checked; - iniFile.SetStringValue("Tunnel", "Ip", tunnelHandler.CurrentTunnel.Address); - iniFile.SetIntValue("Tunnel", "Port", tunnelHandler.CurrentTunnel.Port); + // Let's pack the booleans into bytes + var byteList = Conversions.BoolArrayIntoBytes(optionValues).ToList(); - iniFile.SetIntValue("Settings", "GameID", UniqueGameID); - iniFile.SetBooleanValue("Settings", "Host", IsHost); + while (byteList.Count % 4 != 0) + byteList.Add(0); - PlayerInfo localPlayer = FindLocalPlayer(); + int integerCount = byteList.Count / 4; + byte[] byteArray = byteList.ToArray(); + var sb = new ExtendedStringBuilder(CnCNetCommands.GAME_OPTIONS + " ", true, ';'); - if (localPlayer == null) - return; + for (int i = 0; i < integerCount; i++) + sb.Append(BitConverter.ToInt32(byteArray, i * 4)); - iniFile.SetIntValue("Settings", "Port", localPlayer.Port); - } + // We don't gain much in most cases by packing the drop-down values + // (because they're bytes to begin with, and usually non-zero), + // so let's just transfer them as usual + foreach (GameLobbyDropDown dd in DropDowns) + sb.Append(dd.SelectedIndex); - protected override void SendChatMessage(string message) => channel.SendChatMessage(message, chatColor); + sb.Append(Convert.ToInt32(Map.Official)); + sb.Append(Map.SHA1); + sb.Append(GameMode.Name); + sb.Append(FrameSendRate); + sb.Append(MaxAhead); + sb.Append(ProtocolVersion); + sb.Append(RandomSeed); + sb.Append(Convert.ToInt32(RemoveStartingLocations)); + sb.Append(Map.UntranslatedName); + sb.Append(Convert.ToInt32(v3ConnectionState.DynamicTunnelsEnabled)); - #region Notifications + await channel.SendCTCPMessageAsync(sb.ToString(), QueuedMessageType.GAME_SETTINGS_MESSAGE, 11).ConfigureAwait(false); + } - private void HandleNotification(string sender, Action handler) - { - if (sender != hostName) - return; + private async ValueTask ToggleDynamicTunnelsAsync() + { + await ChangeDynamicTunnelsSettingAsync(!v3ConnectionState.DynamicTunnelsEnabled).ConfigureAwait(false); + await OnGameOptionChangedAsync().ConfigureAwait(false); - handler(); - } + if (!v3ConnectionState.DynamicTunnelsEnabled) + await TunnelSelectionWindow_TunnelSelectedAsync(new(v3ConnectionState.InitialTunnel)).ConfigureAwait(false); + } + + private async ValueTask ToggleP2PAsync() + { + bool p2pEnabled = await v3ConnectionState.ToggleP2PAsync().ConfigureAwait(false); - private void HandleIntNotification(string sender, int parameter, Action handler) + if (p2pEnabled) { - if (sender != hostName) - return; + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} enabled P2P".L10N("Client:Main:P2PEnabled"), FindLocalPlayer().Name)); + await BroadcastPlayerP2PRequestAsync().ConfigureAwait(false); - handler(parameter); + return; } - protected override void GetReadyNotification() - { - base.GetReadyNotification(); -#if WINFORMS - WindowManager.FlashWindow(); -#endif - TopBar.SwitchToPrimary(); + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} disabled P2P".L10N("Client:Main:P2PDisabled"), FindLocalPlayer().Name)); + await SendPlayerP2PRequestAsync().ConfigureAwait(false); + } - if (IsHost) - channel.SendCTCPMessage("GETREADY", QueuedMessageType.GAME_GET_READY_MESSAGE, 0); - } + private async ValueTask ToggleRecordAsync() + { + bool recordingEnabled = await v3ConnectionState.ToggleRecordingAsync().ConfigureAwait(false); - protected override void AISpectatorsNotification() - { - base.AISpectatorsNotification(); + if (recordingEnabled) + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} enabled game recording".L10N("Client:Main:RecordEnabled"), FindLocalPlayer().Name)); + else + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} disabled game recording".L10N("Client:Main:RecordDisabled"), FindLocalPlayer().Name)); + } - if (IsHost) - channel.SendCTCPMessage("AISPECS", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); - } + private void StartReplay(string replayId) + { + } - protected override void InsufficientPlayersNotification() - { - base.InsufficientPlayersNotification(); + /// + /// Handles game option messages received from the game host. + /// + private async ValueTask ApplyGameOptionsAsync(string sender, string message) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - if (IsHost) - channel.SendCTCPMessage("INSFSPLRS", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); - } + string[] parts = message.Split(';'); + int checkBoxIntegerCount = (CheckBoxes.Count / 32) + 1; + int partIndex = checkBoxIntegerCount + DropDowns.Count; - protected override void TooManyPlayersNotification() + if (parts.Length < partIndex + 10) { - base.TooManyPlayersNotification(); - - if (IsHost) - channel.SendCTCPMessage("TMPLRS", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + AddNotice(("The game host has sent an invalid game options message! " + + "The game host's game version might be different from yours.").L10N("Client:Main:HostGameOptionInvalid"), Color.Red); + return; } - protected override void SharedColorsNotification() + string mapOfficial = parts[partIndex]; + bool isMapOfficial = Conversions.BooleanFromString(mapOfficial, true); + string mapHash = parts[partIndex + 1]; + string gameMode = parts[partIndex + 2]; + int frameSendRate = Conversions.IntFromString(parts[partIndex + 3], FrameSendRate); + + if (frameSendRate != FrameSendRate) { - base.SharedColorsNotification(); + FrameSendRate = frameSendRate; - if (IsHost) - channel.SendCTCPMessage("CLRS", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has changed FrameSendRate (order lag) to {0}".L10N("Client:Main:HostChangeFrameSendRate"), frameSendRate)); } - protected override void SharedStartingLocationNotification() + int maxAhead = Conversions.IntFromString(parts[partIndex + 4], MaxAhead); + + if (maxAhead != MaxAhead) { - base.SharedStartingLocationNotification(); + MaxAhead = maxAhead; - if (IsHost) - channel.SendCTCPMessage("SLOC", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has changed MaxAhead to {0}".L10N("Client:Main:HostChangeMaxAhead"), maxAhead)); } - protected override void LockGameNotification() + int protocolVersion = Conversions.IntFromString(parts[partIndex + 5], ProtocolVersion); + + if (protocolVersion != ProtocolVersion) { - base.LockGameNotification(); + ProtocolVersion = protocolVersion; - if (IsHost) - channel.SendCTCPMessage("LCKGME", QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has changed ProtocolVersion to {0}".L10N("Client:Main:HostChangeProtocolVersion"), protocolVersion)); } - protected override void NotVerifiedNotification(int playerIndex) - { - base.NotVerifiedNotification(playerIndex); + string mapName = parts[partIndex + 8]; + GameModeMap currentGameModeMap = GameModeMap; - if (IsHost) - channel.SendCTCPMessage("NVRFY " + playerIndex, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); - } + lastMapHash = mapHash; + lastMapName = mapName; - protected override void StillInGameNotification(int playerIndex) + GameModeMap = GameModeMaps.Find(gmm => gmm.GameMode.Name == gameMode && gmm.Map.SHA1 == mapHash); + + if (GameModeMap == null) { - base.StillInGameNotification(playerIndex); + await ChangeMapAsync(null).ConfigureAwait(false); - if (IsHost) - channel.SendCTCPMessage("INGM " + playerIndex, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0); + if (!isMapOfficial) + await RequestMapAsync().ConfigureAwait(false); + else + await ShowOfficialMapMissingMessageAsync(mapHash).ConfigureAwait(false); } - - private void ReturnNotification(string sender) + else if (GameModeMap != currentGameModeMap) { - AddNotice(string.Format("{0} has returned from the game.".L10N("Client:Main:PlayerReturned"), sender)); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); + } - PlayerInfo pInfo = Players.Find(p => p.Name == sender); + // By changing the game options after changing the map, we know which + // game options were changed by the map and which were changed by the game host - if (pInfo != null) - pInfo.IsInGame = false; + // If the map doesn't exist on the local installation, it's impossible + // to know which options were set by the host and which were set by the + // map, so we'll just assume that the host has set all the options. + // Very few (if any) custom maps force options, so it'll be correct nearly always - sndReturnSound.Play(); - CopyPlayerDataToUI(); - } - - private void HandleTunnelPing(string sender, int ping) + for (int i = 0; i < checkBoxIntegerCount; i++) { - PlayerInfo pInfo = Players.Find(p => p.Name.Equals(sender)); - if (pInfo != null) + if (parts.Length <= i) + return; + + bool success = int.TryParse(parts[i], out int checkBoxStatusInt); + + if (!success) { - pInfo.Ping = ping; - UpdatePlayerPingIndicator(pInfo); + AddNotice(("Failed to parse check box options sent by game host!" + + "The game host's game version might be different from yours.").L10N("Client:Main:HostCheckBoxParseError"), Color.Red); + return; } - } - private void FileHashNotification(string sender, string filesHash) - { - if (!IsHost) - return; + byte[] byteArray = BitConverter.GetBytes(checkBoxStatusInt); + bool[] boolArray = Conversions.BytesIntoBoolArray(byteArray); - PlayerInfo pInfo = Players.Find(p => p.Name == sender); + for (int optionIndex = 0; optionIndex < boolArray.Length; optionIndex++) + { + int gameOptionIndex = (i * 32) + optionIndex; - if (pInfo != null) - pInfo.Verified = true; - CopyPlayerDataToUI(); + if (gameOptionIndex >= CheckBoxes.Count) + break; - if (filesHash != gameFilesHash) - { - channel.SendCTCPMessage("MM " + sender, QueuedMessageType.GAME_CHEATER_MESSAGE, 10); - CheaterNotification(ProgramConstants.PLAYERNAME, sender); - } - } + GameLobbyCheckBox checkBox = CheckBoxes[gameOptionIndex]; - private void CheaterNotification(string sender, string cheaterName) - { - if (sender != hostName) - return; + if (checkBox.Checked != boolArray[optionIndex]) + { + if (boolArray[optionIndex]) + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has enabled {0}".L10N("Client:Main:HostEnableOption"), checkBox.Text)); + else + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has disabled {0}".L10N("Client:Main:HostDisableOption"), checkBox.Text)); + } - AddNotice(string.Format("Player {0} has different files compared to the game host. Either {0} or the game host could be cheating.".L10N("Client:Main:DifferentFileCheating"), cheaterName), Color.Red); + CheckBoxes[gameOptionIndex].Checked = boolArray[optionIndex]; + } } - protected override void BroadcastDiceRoll(int dieSides, int[] results) + for (int i = checkBoxIntegerCount; i < DropDowns.Count + checkBoxIntegerCount; i++) { - string resultString = string.Join(",", results); - channel.SendCTCPMessage($"{DICE_ROLL_MESSAGE} {dieSides},{resultString}", QueuedMessageType.CHAT_MESSAGE, 0); - PrintDiceRollResult(ProgramConstants.PLAYERNAME, dieSides, results); - } + if (parts.Length <= i) + { + AddNotice(("The game host has sent an invalid game options message! " + + "The game host's game version might be different from yours.").L10N("Client:Main:HostGameOptionInvalid"), Color.Red); + return; + } - #endregion + bool success = int.TryParse(parts[i], out int ddSelectedIndex); - protected override void HandleLockGameButtonClick() - { - if (!Locked) + if (!success) { - AddNotice("You've locked the game room.".L10N("Client:Main:RoomLockedByYou")); - LockGame(); + AddNotice(("Failed to parse drop down options sent by game host (2)! " + + "The game host's game version might be different from yours.").L10N("Client:Main:HostGameOptionInvalidTheSecondTime"), Color.Red); + return; } - else + + GameLobbyDropDown dd = DropDowns[i - checkBoxIntegerCount]; + + if (ddSelectedIndex < -1 || ddSelectedIndex >= dd.Items.Count) + continue; + + if (dd.SelectedIndex != ddSelectedIndex) { - if (Players.Count < playerLimit) - { - AddNotice("You've unlocked the game room.".L10N("Client:Main:RoomUnockedByYou")); - UnlockGame(false); - } - else - AddNotice(string.Format( - "Cannot unlock game; the player limit ({0}) has been reached.".L10N("Client:Main:RoomCantUnlockAsLimit"), playerLimit)); + string ddName = dd.OptionName; + + if (dd.OptionName == null) + ddName = dd.Name; + + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has set {0} to {1}".L10N("Client:Main:HostSetOption"), ddName, dd.Items[ddSelectedIndex].Text)); } + + DropDowns[i - checkBoxIntegerCount].SelectedIndex = ddSelectedIndex; } - protected override void LockGame() - { - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("MODE {0} +i", channel.ChannelName), QueuedMessageType.INSTANT_MESSAGE, -1)); + bool parseSuccess = int.TryParse(parts[partIndex + 6], out int randomSeed); - Locked = true; - btnLockGame.Text = "Unlock Game".L10N("Client:Main:UnlockGame"); - AccelerateGameBroadcasting(); + if (!parseSuccess) + { + AddNotice(("Failed to parse random seed from game options message! " + + "The game host's game version might be different from yours.").L10N("Client:Main:HostRandomSeedError"), Color.Red); } - protected override void UnlockGame(bool announce) + bool removeStartingLocations = Convert.ToBoolean(Conversions.IntFromString(parts[partIndex + 7], + Convert.ToInt32(RemoveStartingLocations))); + + SetRandomStartingLocations(removeStartingLocations); + + RandomSeed = randomSeed; + + bool newDynamicTunnelsSetting = Conversions.BooleanFromString(parts[partIndex + 9], true); + + if (newDynamicTunnelsSetting != v3ConnectionState.DynamicTunnelsEnabled) + await ChangeDynamicTunnelsSettingAsync(newDynamicTunnelsSetting).ConfigureAwait(false); + } + + private async ValueTask ChangeDynamicTunnelsSettingAsync(bool newDynamicTunnelsEnabledValue) + { + v3ConnectionState.DynamicTunnelsEnabled = newDynamicTunnelsEnabledValue; + + if (newDynamicTunnelsEnabledValue) + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has enabled Dynamic Tunnels".L10N("Client:Main:HostEnableDynamicTunnels"))); + else + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has disabled Dynamic Tunnels".L10N("Client:Main:HostDisableDynamicTunnels"))); + + if (newDynamicTunnelsEnabledValue) { - connectionManager.SendCustomMessage(new QueuedMessage( - string.Format("MODE {0} -i", channel.ChannelName), QueuedMessageType.INSTANT_MESSAGE, -1)); + tunnelHandler.CurrentTunnel = v3ConnectionState.GetEligibleTunnels().MinBy(q => q.PingInMs); - Locked = false; - if (announce) - AddNotice("The game room has been unlocked.".L10N("Client:Main:GameRoomUnlocked")); - btnLockGame.Text = "Lock Game".L10N("Client:Main:LockGame"); - AccelerateGameBroadcasting(); + await BroadcastPlayerTunnelPingsAsync().ConfigureAwait(false); } + } - protected override void KickPlayer(int playerIndex) + private async ValueTask RequestMapAsync() + { + if (UserINISettings.Instance.EnableMapSharing) { - if (playerIndex >= Players.Count) - return; + AddNotice("The game host has selected a map that doesn't exist on your installation.".L10N("Client:Main:MapNotExist")); + mapSharingConfirmationPanel.ShowForMapDownload(); + } + else + { + AddNotice("The game host has selected a map that doesn't exist on your installation.".L10N("Client:Main:MapNotExist") + " " + + ("Because you've disabled map sharing, it cannot be transferred. The game host needs " + + "to change the map or you will be unable to participate in the match.").L10N("Client:Main:MapSharingDisabledNotice")); + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_DISABLED, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + } + } + + private ValueTask ShowOfficialMapMissingMessageAsync(string sha1) + { + AddNotice(("The game host has selected an official map that doesn't exist on your installation. " + + "This could mean that the game host has modified game files, or is running a different game version. " + + "They need to change the map or you will be unable to participate in the match.").L10N("Client:Main:OfficialMapNotExist")); + return channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_FAIL + " " + sha1, QueuedMessageType.SYSTEM_MESSAGE, 9); + } - var pInfo = Players[playerIndex]; + private void MapSharingConfirmationPanel_MapDownloadConfirmed(object sender, EventArgs e) + { + Logger.Log("Map sharing confirmed."); + AddNotice("Attempting to download map.".L10N("Client:Main:DownloadingMap")); + mapSharingConfirmationPanel.SetDownloadingStatus(); + MapSharer.DownloadMap(lastMapHash, localGame, lastMapName); + } + + protected override ValueTask ChangeMapAsync(GameModeMap gameModeMap) + { + mapSharingConfirmationPanel.Disable(); + return base.ChangeMapAsync(gameModeMap); + } - AddNotice(string.Format("Kicking {0} from the game...".L10N("Client:Main:KickPlayer"), pInfo.Name)); - channel.SendKickMessage(pInfo.Name, 8); + /// + /// Signals other players that the local player has returned from the game, + /// and unlocks the game as well as generates a new random seed as the game host. + /// + protected override async ValueTask GameProcessExitedAsync() + { + await base.GameProcessExitedAsync().ConfigureAwait(false); + await channel.SendCTCPMessageAsync(CnCNetCommands.RETURN, QueuedMessageType.SYSTEM_MESSAGE, 20).ConfigureAwait(false); + gameStartCancellationTokenSource?.Cancel(); + await v3ConnectionState.SaveReplayAsync().ConfigureAwait(false); + await v3ConnectionState.ClearConnectionsAsync().ConfigureAwait(false); + ReturnNotification(ProgramConstants.PLAYERNAME); + + if (IsHost) + { + RandomSeed = new Random().Next(); + await OnGameOptionChangedAsync().ConfigureAwait(false); + ClearReadyStatuses(); + CopyPlayerDataToUI(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); + + if (Players.Count < playerLimit) + await UnlockGameAsync(true).ConfigureAwait(false); } + } - protected override void BanPlayer(int playerIndex) - { - if (playerIndex >= Players.Count) - return; + /// + /// Handles the "START" (game start) command sent by the game host. + /// + private async ValueTask ClientLaunchGameV2Async(string sender, string message) + { + if (tunnelHandler.CurrentTunnel.Version != Constants.TUNNEL_VERSION_2) + return; - var pInfo = Players[playerIndex]; + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - var user = connectionManager.UserList.Find(u => u.Name == pInfo.Name); + string[] parts = message.Split(';'); - if (user != null) - { - AddNotice(string.Format("Banning and kicking {0} from the game...".L10N("Client:Main:BanAndKickPlayer"), pInfo.Name)); - channel.SendBanMessage(user.Hostname, 8); - channel.SendKickMessage(user.Name, 8); - } - } + if (parts.Length < 1) + return; + + UniqueGameID = Conversions.IntFromString(parts[0], -1); + if (UniqueGameID < 0) + return; - private void HandleCheatDetectedMessage(string sender) => - AddNotice(string.Format("{0} has modified game files during the client session. They are likely attempting to cheat!".L10N("Client:Main:PlayerModifyFileCheat"), sender), Color.Red); + var recentPlayers = new List(); - private void HandleTunnelServerChangeMessage(string sender, string tunnelAddressAndPort) + for (int i = 1; i < parts.Length; i += 2) { - if (sender != hostName) + if (parts.Length <= i + 1) return; - string[] split = tunnelAddressAndPort.Split(':'); - string tunnelAddress = split[0]; - int tunnelPort = int.Parse(split[1]); + string pName = parts[i]; + string[] ipAndPort = parts[i + 1].Split(':'); - CnCNetTunnel tunnel = tunnelHandler.Tunnels.Find(t => t.Address == tunnelAddress && t.Port == tunnelPort); - if (tunnel == null) - { - tunnelErrorMode = true; - AddNotice(("The game host has selected an invalid tunnel server! " + - "The game host needs to change the server or you will be unable " + - "to participate in the match.").L10N("Client:Main:HostInvalidTunnel"), - Color.Yellow); - UpdateLaunchGameButtonStatus(); + if (ipAndPort.Length < 2) return; - } - tunnelErrorMode = false; - HandleTunnelServerChange(tunnel); - UpdateLaunchGameButtonStatus(); + bool success = int.TryParse(ipAndPort[1], out int port); + + if (!success) + return; + + PlayerInfo pInfo = Players.Find(p => p.Name == pName); + + if (pInfo == null) + return; + + pInfo.Port = port; + recentPlayers.Add(pName); } - /// - /// Changes the tunnel server used for the game. - /// - /// The new tunnel server to use. - private void HandleTunnelServerChange(CnCNetTunnel tunnel) + cncnetUserData.AddRecentPlayers(recentPlayers, channel.UIName); + await StartGameAsync().ConfigureAwait(false); + } + + protected override async ValueTask StartGameAsync() + { + AddNotice("Starting game...".L10N("Client:Main:StartingGame")); + + isStartingGame = false; + + var fhc = new FileHashCalculator(); + + fhc.CalculateHashes(GameModeMaps.GameModes); + + if (gameFilesHash != fhc.GetCompleteHash()) { - tunnelHandler.CurrentTunnel = tunnel; - AddNotice(string.Format("The game host has changed the tunnel server to: {0}".L10N("Client:Main:HostChangeTunnel"), tunnel.Name)); - UpdatePing(); + Logger.Log("Game files modified during client session!"); + await channel.SendCTCPMessageAsync(CnCNetCommands.CHEAT_DETECTED, QueuedMessageType.INSTANT_MESSAGE, 0).ConfigureAwait(false); + HandleCheatDetectedMessage(ProgramConstants.PLAYERNAME); } - protected override bool UpdateLaunchGameButtonStatus() + await base.StartGameAsync().ConfigureAwait(false); + } + + protected override void WriteSpawnIniAdditions(IniFile iniFile) + { + base.WriteSpawnIniAdditions(iniFile); + + if (tunnelHandler.CurrentTunnel?.Version == Constants.TUNNEL_VERSION_2) { - btnLaunchGame.Enabled = base.UpdateLaunchGameButtonStatus() && !tunnelErrorMode; - return btnLaunchGame.Enabled; + iniFile.SetStringValue("Tunnel", "Ip", tunnelHandler.CurrentTunnel.Address); + iniFile.SetIntValue("Tunnel", "Port", tunnelHandler.CurrentTunnel.Port); } - #region CnCNet map sharing + iniFile.SetIntValue("Settings", "GameID", UniqueGameID); + iniFile.SetBooleanValue("Settings", "Host", IsHost); + + PlayerInfo localPlayer = FindLocalPlayer(); + + if (localPlayer == null) + return; + + iniFile.SetIntValue("Settings", "Port", localPlayer.Port); + } + + protected override ValueTask SendChatMessageAsync(string message) => channel.SendChatMessageAsync(message, chatColor); + + private void HandleNotification(string sender, Action handler) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; + + handler(); + } + + private void HandleIntNotification(string sender, int parameter, Action handler) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; + + handler(parameter); + } + + protected override async ValueTask GetReadyNotificationAsync() + { + await base.GetReadyNotificationAsync().ConfigureAwait(false); +#if WINFORMS + WindowManager.FlashWindow(); +#endif + TopBar.SwitchToPrimary(); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.GET_READY_LOBBY, QueuedMessageType.GAME_GET_READY_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask AISpectatorsNotificationAsync() + { + await base.AISpectatorsNotificationAsync().ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.AI_SPECTATORS, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask InsufficientPlayersNotificationAsync() + { + await base.InsufficientPlayersNotificationAsync().ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.INSUFFICIENT_PLAYERS, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask TooManyPlayersNotificationAsync() + { + await base.TooManyPlayersNotificationAsync().ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.TOO_MANY_PLAYERS, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask SharedColorsNotificationAsync() + { + await base.SharedColorsNotificationAsync().ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.SHARED_COLORS, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask SharedStartingLocationNotificationAsync() + { + await base.SharedStartingLocationNotificationAsync().ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.SHARED_STARTING_LOCATIONS, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask LockGameNotificationAsync() + { + await base.LockGameNotificationAsync().ConfigureAwait(false); - private void MapSharer_MapDownloadFailed(object sender, SHA1EventArgs e) - => WindowManager.AddCallback(new Action(MapSharer_HandleMapDownloadFailed), e); + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.LOCK_GAME, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask NotVerifiedNotificationAsync(int playerIndex) + { + await base.NotVerifiedNotificationAsync(playerIndex).ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.NOT_VERIFIED + " " + playerIndex, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + protected override async ValueTask StillInGameNotificationAsync(int playerIndex) + { + await base.StillInGameNotificationAsync(playerIndex).ConfigureAwait(false); + + if (IsHost) + await channel.SendCTCPMessageAsync(CnCNetCommands.STILL_IN_GAME + " " + playerIndex, QueuedMessageType.GAME_NOTIFICATION_MESSAGE, 0).ConfigureAwait(false); + } + + private void ReturnNotification(string sender) + { + AddNotice(string.Format(CultureInfo.CurrentCulture, "{0} has returned from the game.".L10N("Client:Main:PlayerReturned"), sender)); - private void MapSharer_HandleMapDownloadFailed(SHA1EventArgs e) + PlayerInfo pInfo = Players.Find(p => p.Name == sender); + + if (pInfo != null) + pInfo.IsInGame = false; + + sndReturnSound.Play(); + CopyPlayerDataToUI(); + } + + private void HandleTunnelPing(string sender, int ping) + { + PlayerInfo pInfo = Players.Find(p => p.Name.Equals(sender, StringComparison.OrdinalIgnoreCase)); + + if (pInfo != null) { - // If the host has already uploaded the map, we shouldn't request them to re-upload it - if (hostUploadedMaps.Contains(e.SHA1)) - { - AddNotice("Download of the custom map failed. The host needs to change the map or you will be unable to participate in this match.".L10N("Client:Main:DownloadCustomMapFailed")); - mapSharingConfirmationPanel.SetFailedStatus(); + pInfo.Ping = ping; - channel.SendCTCPMessage(MAP_SHARING_FAIL_MESSAGE + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9); - return; - } - else if (chatCommandDownloadedMaps.Contains(e.SHA1)) - { - // Notify the user that their chat command map download failed. - // Do not notify other users with a CTCP message as this is irrelevant to them. - AddNotice("Downloading map via chat command has failed. Check the map ID and try again.".L10N("Client:Main:DownloadMapCommandFailedGeneric")); - mapSharingConfirmationPanel.SetFailedStatus(); - return; - } + UpdatePlayerPingIndicator(pInfo); + } + } + + private async ValueTask FileHashNotificationAsync(string sender, string filesHash) + { + if (!IsHost) + return; - AddNotice("Requesting the game host to upload the map to the CnCNet map database.".L10N("Client:Main:RequestHostUploadMapToDB")); + PlayerInfo pInfo = Players.Find(p => p.Name == sender); - channel.SendCTCPMessage(MAP_SHARING_UPLOAD_REQUEST + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9); + if (pInfo != null) + pInfo.Verified = true; + + CopyPlayerDataToUI(); + + if (filesHash != gameFilesHash) + { + await channel.SendCTCPMessageAsync(CnCNetCommands.CHEATER + " " + sender, QueuedMessageType.GAME_CHEATER_MESSAGE, 10).ConfigureAwait(false); + CheaterNotification(ProgramConstants.PLAYERNAME, sender); } + } - private void MapSharer_MapDownloadComplete(object sender, SHA1EventArgs e) => - WindowManager.AddCallback(new Action(MapSharer_HandleMapDownloadComplete), e); + private void CheaterNotification(string sender, string cheaterName) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - private void MapSharer_HandleMapDownloadComplete(SHA1EventArgs e) + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} has different files compared to the game host. Either {0} or the game host could be cheating.".L10N("Client:Main:DifferentFileCheating"), cheaterName), Color.Red); + } + + protected override async ValueTask BroadcastDiceRollAsync(int dieSides, int[] results) + { + string resultString = string.Join(",", results); + + await channel.SendCTCPMessageAsync($"{CnCNetCommands.DICE_ROLL} {dieSides},{resultString}", QueuedMessageType.CHAT_MESSAGE, 0).ConfigureAwait(false); + PrintDiceRollResult(ProgramConstants.PLAYERNAME, dieSides, results); + } + + protected override async ValueTask HandleLockGameButtonClickAsync() + { + if (!Locked) { - string mapFileName = MapSharer.GetMapFileName(e.SHA1, e.MapName); - Logger.Log("Map " + mapFileName + " downloaded, parsing."); - string mapPath = "Maps/Custom/" + mapFileName; - Map map = MapLoader.LoadCustomMap(mapPath, out string returnMessage); - if (map != null) - { - AddNotice(returnMessage); - if (lastMapSHA1 == e.SHA1) - { - GameModeMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == lastMapSHA1); - ChangeMap(GameModeMap); - } - } - else if (chatCommandDownloadedMaps.Contains(e.SHA1)) + AddNotice("You've locked the game room.".L10N("Client:Main:RoomLockedByYou")); + await LockGameAsync().ConfigureAwait(false); + } + else + { + if (Players.Count < playerLimit) { - // Somehow the user has managed to download an already existing sha1 hash. - // This special case prevents user confusion from the file successfully downloading but showing an error anyway. - AddNotice(returnMessage, Color.Yellow); - AddNotice("Map was downloaded, but a duplicate is already loaded from a different filename. This may cause strange behavior.".L10N("Client:Main:DownloadMapCommandDuplicateMapFileLoaded"), - Color.Yellow); + AddNotice("You've unlocked the game room.".L10N("Client:Main:RoomUnockedByYou")); + await UnlockGameAsync(false).ConfigureAwait(false); } else { - AddNotice(returnMessage, Color.Red); - AddNotice("Transfer of the custom map failed. The host needs to change the map or you will be unable to participate in this match.".L10N("Client:Main:MapTransferFailed")); - mapSharingConfirmationPanel.SetFailedStatus(); - channel.SendCTCPMessage(MAP_SHARING_FAIL_MESSAGE + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9); + AddNotice(string.Format(CultureInfo.CurrentCulture, "Cannot unlock game; the player limit ({0}) has been reached.".L10N("Client:Main:RoomCantUnlockAsLimit"), playerLimit)); } } + } + + protected override async ValueTask LockGameAsync() + { + await connectionManager.SendCustomMessageAsync( + new(FormattableString.Invariant($"{IRCCommands.MODE} {channel.ChannelName} +{IRCChannelModes.INVITE_ONLY}"), QueuedMessageType.INSTANT_MESSAGE, -1)).ConfigureAwait(false); + + Locked = true; + btnLockGame.Text = "Unlock Game".L10N("Client:Main:UnlockGame"); + AccelerateGameBroadcasting(); + } + + protected override async ValueTask UnlockGameAsync(bool announce) + { + await connectionManager.SendCustomMessageAsync( + new(FormattableString.Invariant($"{IRCCommands.MODE} {channel.ChannelName} -{IRCChannelModes.INVITE_ONLY}"), QueuedMessageType.INSTANT_MESSAGE, -1)).ConfigureAwait(false); + + Locked = false; - private void MapSharer_MapUploadFailed(object sender, MapEventArgs e) => - WindowManager.AddCallback(new Action(MapSharer_HandleMapUploadFailed), e); + if (announce) + AddNotice("The game room has been unlocked.".L10N("Client:Main:GameRoomUnlocked")); - private void MapSharer_HandleMapUploadFailed(MapEventArgs e) + btnLockGame.Text = "Lock Game".L10N("Client:Main:LockGame"); + AccelerateGameBroadcasting(); + } + + protected override async ValueTask KickPlayerAsync(int playerIndex) + { + if (playerIndex >= Players.Count) + return; + + PlayerInfo pInfo = Players[playerIndex]; + + AddNotice(string.Format(CultureInfo.CurrentCulture, "Kicking {0} from the game...".L10N("Client:Main:KickPlayer"), pInfo.Name)); + await channel.SendKickMessageAsync(pInfo.Name, 8).ConfigureAwait(false); + } + + protected override async ValueTask BanPlayerAsync(int playerIndex) + { + if (playerIndex >= Players.Count) + return; + + PlayerInfo pInfo = Players[playerIndex]; + IRCUser user = connectionManager.UserList.Find(u => u.Name == pInfo.Name); + + if (user != null) { - Map map = e.Map; + AddNotice(string.Format(CultureInfo.CurrentCulture, "Banning and kicking {0} from the game...".L10N("Client:Main:BanAndKickPlayer"), pInfo.Name)); + await channel.SendBanMessageAsync(user.Hostname, 8).ConfigureAwait(false); + await channel.SendKickMessageAsync(user.Name, 8).ConfigureAwait(false); + } + } - hostUploadedMaps.Add(map.SHA1); + private void HandleCheatDetectedMessage(string sender) => + AddNotice(string.Format(CultureInfo.CurrentCulture, "{0} has modified game files during the client session. They are likely attempting to cheat!".L10N("Client:Main:PlayerModifyFileCheat"), sender), Color.Red); - AddNotice(string.Format("Uploading map {0} to the CnCNet map database failed.".L10N("Client:Main:UpdateMapToDBFailed"), map.Name)); - if (map == Map) - { - AddNotice("You need to change the map or some players won't be able to participate in this match.".L10N("Client:Main:YouMustReplaceMap")); - channel.SendCTCPMessage(MAP_SHARING_FAIL_MESSAGE + " " + map.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9); - } + private async ValueTask HandleTunnelServerChangeMessageAsync(string sender, string hash) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; + + CnCNetTunnel tunnel = tunnelHandler.Tunnels.Find(t => t.Hash.Equals(hash, StringComparison.OrdinalIgnoreCase)); + + if (tunnel == null) + { + tunnelErrorMode = true; + AddNotice(("The game host has selected an invalid tunnel server! " + + "The game host needs to change the server or you will be unable " + + "to participate in the match.").L10N("Client:Main:HostInvalidTunnel"), + Color.Yellow); + UpdateLaunchGameButtonStatus(); + return; } - private void MapSharer_MapUploadComplete(object sender, MapEventArgs e) => - WindowManager.AddCallback(new Action(MapSharer_HandleMapUploadComplete), e); + tunnelErrorMode = false; + await HandleTunnelServerChangeAsync(tunnel).ConfigureAwait(false); + UpdateLaunchGameButtonStatus(); + } + + private void HandleTunnelPingsMessage(string playerName, string tunnelPingsMessage) + { + if (!v3ConnectionState.PinnedTunnels.Any()) + return; + + string selectedTunnelHash = v3ConnectionState.HandleTunnelPingsMessage(playerName, tunnelPingsMessage); - private void MapSharer_HandleMapUploadComplete(MapEventArgs e) + if (selectedTunnelHash is null) + { + AddNotice(string.Format(CultureInfo.CurrentCulture, "No common dynamic tunnel found for: {0}".L10N("Client:Main:NoCommonDynamicTunnel"), playerName)); + } + else { - hostUploadedMaps.Add(e.Map.SHA1); + CnCNetTunnel tunnel = tunnelHandler.Tunnels.Single(q => q.Hash.Equals(selectedTunnelHash, StringComparison.OrdinalIgnoreCase)); - AddNotice(string.Format("Uploading map {0} to the CnCNet map database complete.".L10N("Client:Main:UpdateMapToDBSuccess"), e.Map.Name)); - if (e.Map == Map) - { - channel.SendCTCPMessage(MAP_SHARING_DOWNLOAD_REQUEST + " " + Map.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9); - } + AddNotice(string.Format(CultureInfo.CurrentCulture, "{0} dynamic tunnel: {1} ({2}ms)".L10N("Client:Main:DynamicTunnelNegotiated"), playerName, tunnel.Name, tunnel.PingInMs)); } + } + + private async ValueTask HandleP2PRequestMessageAsync(string playerName, string p2pRequestMessage, bool isCachedP2PRequestMessage) + { + if (!isCachedP2PRequestMessage) + v3ConnectionState.StoreP2PRequest(playerName, p2pRequestMessage); + + if (!v3ConnectionState.P2PEnabled) + return; + + bool remotePlayerP2PEnabled = await v3ConnectionState.PingRemotePlayerAsync(playerName, p2pRequestMessage).ConfigureAwait(false); - /// - /// Handles a map upload request sent by a player. - /// - /// The sender of the request. - /// The SHA1 of the requested map. - private void HandleMapUploadRequest(string sender, string mapSHA1) + if (remotePlayerP2PEnabled) { - if (hostUploadedMaps.Contains(mapSHA1)) - { - Logger.Log("HandleMapUploadRequest: Map " + mapSHA1 + " is already uploaded!"); - return; - } + if (isCachedP2PRequestMessage) + ShowP2PPlayerStatus(playerName); - Map map = null; + await channel.SendCTCPMessageAsync( + CnCNetCommands.PLAYER_P2P_PINGS + v3ConnectionState.GetP2PPingCommand(playerName), QueuedMessageType.SYSTEM_MESSAGE, 10).ConfigureAwait(false); + } + else + { + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} disabled P2P".L10N("Client:Main:P2PDisabled"), playerName)); + } + } - foreach (GameMode gm in GameModeMaps.GameModes) - { - map = gm.Maps.Find(m => m.SHA1 == mapSHA1); + private async ValueTask HandleP2PPingsMessageAsync(string playerName, string p2pPingsMessage) + { + string cachedP2PRequestMessage = v3ConnectionState.UpdateRemotePingResults(playerName, p2pPingsMessage, FindLocalPlayer().Name); - if (map != null) - break; - } + if (!string.IsNullOrWhiteSpace(cachedP2PRequestMessage)) + await HandleP2PRequestMessageAsync(playerName, cachedP2PRequestMessage, false).ConfigureAwait(false); - if (map == null) - { - Logger.Log("Unknown map upload request from " + sender + ": " + mapSHA1); - return; - } + ShowP2PPlayerStatus(playerName); + } - if (map.Official) - { - Logger.Log("HandleMapUploadRequest: Map is official, so skip request"); + private void ShowP2PPlayerStatus(string playerName) + { + P2PPlayer p2pPlayer = v3ConnectionState.P2PPlayers.Single(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase)); - AddNotice(string.Format(("{0} doesn't have the map '{1}' on their local installation. " + - "The map needs to be changed or {0} is unable to participate in the match.").L10N("Client:Main:PlayerMissingMap"), - sender, map.Name)); + if (p2pPlayer.RemotePingResults.Any() && p2pPlayer.LocalPingResults.Any()) + AddNotice(string.Format(CultureInfo.CurrentCulture, "Player {0} P2P negotiated ({1}ms)".L10N("Client:Main:PlayerP2PNegotiated"), playerName, p2pPlayer.LocalPingResults.Min(q => q.Ping))); + } - return; - } + /// + /// Changes the tunnel server used for the game. + /// + /// The new tunnel server to use. + private ValueTask HandleTunnelServerChangeAsync(CnCNetTunnel tunnel) + { + tunnelHandler.CurrentTunnel = tunnel; - if (!IsHost) - return; + AddNotice(string.Format(CultureInfo.CurrentCulture, "The game host has changed the tunnel server to: {0}".L10N("Client:Main:HostChangeTunnel"), tunnel.Name)); + return UpdatePingAsync(); + } - AddNotice(string.Format(("{0} doesn't have the map '{1}' on their local installation. " + - "Attempting to upload the map to the CnCNet map database.").L10N("Client:Main:UpdateMapToDBPrompt"), - sender, map.Name)); + protected override bool UpdateLaunchGameButtonStatus() + { + btnLaunchGame.Enabled = base.UpdateLaunchGameButtonStatus() && !tunnelErrorMode; + return btnLaunchGame.Enabled; + } - MapSharer.UploadMap(map, localGame); + private async ValueTask MapSharer_HandleMapDownloadFailedAsync(SHA1EventArgs e) + { + // If the host has already uploaded the map, we shouldn't request them to re-upload it + if (hostUploadedMaps.Contains(e.SHA1)) + { + AddNotice("Download of the custom map failed. The host needs to change the map or you will be unable to participate in this match.".L10N("Client:Main:DownloadCustomMapFailed")); + mapSharingConfirmationPanel.SetFailedStatus(); + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_FAIL + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + return; } - /// - /// Handles a map transfer failure message sent by either the player or the game host. - /// - private void HandleMapTransferFailMessage(string sender, string sha1) + if (chatCommandDownloadedMaps.Contains(e.SHA1)) { - if (sender == hostName) - { - AddNotice("The game host failed to upload the map to the CnCNet map database.".L10N("Client:Main:HostUpdateMapToDBFailed")); + // Notify the user that their chat command map download failed. + // Do not notify other users with a CTCP message as this is irrelevant to them. + AddNotice("Downloading map via chat command has failed. Check the map ID and try again.".L10N("Client:Main:DownloadMapCommandFailedGeneric")); + mapSharingConfirmationPanel.SetFailedStatus(); + return; + } - hostUploadedMaps.Add(sha1); + AddNotice("Requesting the game host to upload the map to the CnCNet map database.".L10N("Client:Main:RequestHostUploadMapToDB")); + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_UPLOAD + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + } - if (lastMapSHA1 == sha1 && Map == null) - { - AddNotice("The game host needs to change the map or you won't be able to participate in this match.".L10N("Client:Main:HostMustChangeMap")); - } + private async ValueTask MapSharer_HandleMapDownloadCompleteAsync(SHA1EventArgs e) + { + string mapFileName = MapSharer.GetMapFileName(e.SHA1, e.MapName); + Logger.Log("Map " + mapFileName + " downloaded, parsing."); + string mapPath = "Maps/Custom/" + mapFileName; + Map map = MapLoader.LoadCustomMap(mapPath, out string returnMessage); - return; - } + if (map != null) + { + AddNotice(returnMessage); - if (lastMapSHA1 == sha1) + if (lastMapHash == e.SHA1) { - if (!IsHost) - { - AddNotice(string.Format("{0} has failed to download the map from the CnCNet map database.".L10N("Client:Main:PlayerDownloadMapFailed") + " " + - "The host needs to change the map or {0} won't be able to participate in this match.".L10N("Client:Main:HostNeedChangeMapForPlayer"), sender)); - } - else - { - AddNotice(string.Format("{0} has failed to download the map from the CnCNet map database.".L10N("Client:Main:PlayerDownloadMapFailed") + " " + - "You need to change the map or {0} won't be able to participate in this match.".L10N("Client:Main:YouNeedChangeMapForPlayer"), sender)); - } + GameModeMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == lastMapHash); + + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); } } + else if (chatCommandDownloadedMaps.Contains(e.SHA1)) + { + // Somehow the user has managed to download an already existing sha1 hash. + // This special case prevents user confusion from the file successfully downloading but showing an error anyway. + AddNotice(returnMessage, Color.Yellow); + AddNotice( + "Map was downloaded, but a duplicate is already loaded from a different filename. This may cause strange behavior.".L10N("Client:Main:DownloadMapCommandDuplicateMapFileLoaded"), + Color.Yellow); + } + else + { + AddNotice(returnMessage, Color.Red); + AddNotice("Transfer of the custom map failed. The host needs to change the map or you will be unable to participate in this match.".L10N("Client:Main:MapTransferFailed")); + mapSharingConfirmationPanel.SetFailedStatus(); + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_FAIL + " " + e.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + } + } - private void HandleMapDownloadRequest(string sender, string sha1) + private async ValueTask MapSharer_HandleMapUploadFailedAsync(MapEventArgs e) + { + Map map = e.Map; + + hostUploadedMaps.Add(map.SHA1); + AddNotice(string.Format(CultureInfo.CurrentCulture, "Uploading map {0} to the CnCNet map database failed.".L10N("Client:Main:UpdateMapToDBFailed"), map.Name)); + + if (map == Map) { - if (sender != hostName) - return; + AddNotice("You need to change the map or some players won't be able to participate in this match.".L10N("Client:Main:YouMustReplaceMap")); + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_FAIL + " " + map.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + } + } + + private async ValueTask MapSharer_HandleMapUploadCompleteAsync(MapEventArgs e) + { + hostUploadedMaps.Add(e.Map.SHA1); + AddNotice(string.Format(CultureInfo.CurrentCulture, "Uploading map {0} to the CnCNet map database complete.".L10N("Client:Main:UpdateMapToDBSuccess"), e.Map.Name)); + + if (e.Map == Map) + { + await channel.SendCTCPMessageAsync(CnCNetCommands.MAP_SHARING_DOWNLOAD + " " + Map.SHA1, QueuedMessageType.SYSTEM_MESSAGE, 9).ConfigureAwait(false); + } + } + + /// + /// Handles a map upload request sent by a player. + /// + /// The sender of the request. + /// The SHA1 of the requested map. + private void HandleMapUploadRequest(string sender, string mapHash) + { + if (hostUploadedMaps.Contains(mapHash)) + { + Logger.Log("HandleMapUploadRequest: Map " + mapHash + " is already uploaded!"); + return; + } + + Map map = null; + + foreach (GameMode gm in GameModeMaps.GameModes) + { + map = gm.Maps.Find(m => m.SHA1 == mapHash); + + if (map != null) + break; + } + + if (map == null) + { + Logger.Log("Unknown map upload request from " + sender + ": " + mapHash); + return; + } + + if (map.Official) + { + Logger.Log("HandleMapUploadRequest: Map is official, so skip request"); + AddNotice( + string.Format(CultureInfo.CurrentCulture, ("{0} doesn't have the map '{1}' on their local installation. " + + "The map needs to be changed or {0} is unable to participate in the match.").L10N("Client:Main:PlayerMissingMap"), + sender, + map.Name)); + return; + } + + if (!IsHost) + return; + + AddNotice( + string.Format(CultureInfo.CurrentCulture, ("{0} doesn't have the map '{1}' on their local installation. " + + "Attempting to upload the map to the CnCNet map database.").L10N("Client:Main:UpdateMapToDBPrompt"), + sender, + map.Name)); + MapSharer.UploadMap(map, localGame); + } + + /// + /// Handles a map transfer failure message sent by either the player or the game host. + /// + private void HandleMapTransferFailMessage(string sender, string sha1) + { + if (sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + { + AddNotice("The game host failed to upload the map to the CnCNet map database.".L10N("Client:Main:HostUpdateMapToDBFailed")); hostUploadedMaps.Add(sha1); - if (lastMapSHA1 == sha1 && Map == null) - { - Logger.Log("The game host has uploaded the map into the database. Re-attempting download..."); - MapSharer.DownloadMap(sha1, localGame, lastMapName); - } + if (lastMapHash == sha1 && Map == null) + AddNotice("The game host needs to change the map or you won't be able to participate in this match.".L10N("Client:Main:HostMustChangeMap")); + + return; } - private void HandleMapSharingBlockedMessage(string sender) - { - AddNotice(string.Format(("The selected map doesn't exist on {0}'s installation, and they " + - "have map sharing disabled in settings. The game host needs to change to a non-custom map or " + - "they will be unable to participate in this match.").L10N("Client:Main:PlayerMissingMapDisabledSharing"), sender)); - } - - /// - /// Download a map from CNCNet using a map hash ID. - /// - /// Users and testers can get map hash IDs from this URL template: - /// - /// - http://mapdb.cncnet.org/search.php?game=GAME_ID&search=MAP_NAME_SEARCH_STRING - /// - /// - /// - /// This is a string beginning with the sha1 hash map ID, and (optionally) the name to use as a local filename for the map file. - /// Every character after the first space will be treated as part of the map name. - /// - /// "?" characters are removed from the sha1 due to weird copy and paste behavior from the map search endpoint. - /// - private void DownloadMapByIdCommand(string parameters) - { - string sha1; - string mapName; - string message; - - // Make sure no spaces at the beginning or end of the string will mess up arg parsing. - parameters = parameters.Trim(); - // Check if the parameter's contain spaces. - // The presence of spaces indicates a user-specified map name. - int firstSpaceIndex = parameters.IndexOf(' '); - - if (firstSpaceIndex == -1) + if (lastMapHash == sha1) + { + if (!IsHost) { - // The user did not supply a map name. - sha1 = parameters; - mapName = "user_chat_command_download"; + AddNotice( + string.Format(CultureInfo.CurrentCulture, "{0} has failed to download the map from the CnCNet map database.".L10N("Client:Main:PlayerDownloadMapFailed") + " " + + "The host needs to change the map or {0} won't be able to participate in this match.".L10N("Client:Main:HostNeedChangeMapForPlayer"), + sender)); } else { - // User supplied a map name. - sha1 = parameters.Substring(0, firstSpaceIndex); - mapName = parameters.Substring(firstSpaceIndex + 1); - mapName = mapName.Trim(); + AddNotice( + string.Format(CultureInfo.CurrentCulture, "{0} has failed to download the map from the CnCNet map database.".L10N("Client:Main:PlayerDownloadMapFailed") + " " + + "You need to change the map or {0} won't be able to participate in this match.".L10N("Client:Main:YouNeedChangeMapForPlayer"), + sender)); } + } + } - // Remove erroneous "?". These sneak in when someone double-clicks a map ID and copies it from the cncnet search endpoint. - // There is some weird whitespace that gets copied to chat as a "?" at the end of the hash. It's hard to spot, so just hold the user's hand. - sha1 = sha1.Replace("?", ""); + private void HandleMapDownloadRequest(string sender, string sha1) + { + if (!sender.Equals(hostName, StringComparison.OrdinalIgnoreCase)) + return; - // See if the user already has this map, with any filename, before attempting to download it. - GameModeMap loadedMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == sha1); + hostUploadedMaps.Add(sha1); - if (loadedMap != null) - { - message = String.Format( - "The map for ID \"{0}\" is already loaded from \"{1}.map\", delete the existing file before trying again.".L10N("Client:Main:DownloadMapCommandSha1AlreadyExists"), - sha1, - loadedMap.Map.BaseFilePath); - AddNotice(message, Color.Yellow); - Logger.Log(message); - return; - } + if (lastMapHash == sha1 && Map == null) + { + Logger.Log("The game host has uploaded the map into the database. Re-attempting download..."); + MapSharer.DownloadMap(sha1, localGame, lastMapName); + } + } - // Replace any characters that are not safe for filenames. - char replaceUnsafeCharactersWith = '-'; - // Use a hashset instead of an array for quick lookups in `invalidChars.Contains()`. - HashSet invalidChars = new HashSet(Path.GetInvalidFileNameChars()); - string safeMapName = new String(mapName.Select(c => invalidChars.Contains(c) ? replaceUnsafeCharactersWith : c).ToArray()); + private void HandleMapSharingBlockedMessage(string sender) + { + AddNotice( + string.Format(CultureInfo.CurrentCulture, "The selected map doesn't exist on {0}'s installation, and they " + + "have map sharing disabled in settings. The game host needs to change to a non-custom map or " + + "they will be unable to participate in this match.".L10N("Client:Main:PlayerMissingMapDisabledSharing"), + sender)); + } - chatCommandDownloadedMaps.Add(sha1); + /// + /// Download a map from CNCNet using a map hash ID. + /// + /// Users and testers can get map hash IDs from this URL template: + /// + /// - https://mapdb.cncnet.org/search.php?game=GAME_ID&search=MAP_NAME_SEARCH_STRING. + /// + /// + /// + /// This is a string beginning with the sha1 hash map ID, and (optionally) the name to use as a local filename for the map file. + /// Every character after the first space will be treated as part of the map name. + /// + /// "?" characters are removed from the sha1 due to weird copy and paste behavior from the map search endpoint. + /// + private void DownloadMapByIdCommand(string parameters) + { + string sha1; + string mapName; + string message; - message = String.Format("Attempting to download map via chat command: sha1={0}, mapName={1}".L10N("Client:Main:DownloadMapCommandStartingDownload"), sha1, mapName); - Logger.Log(message); - AddNotice(message); + // Make sure no spaces at the beginning or end of the string will mess up arg parsing. + parameters = parameters.Trim(); - MapSharer.DownloadMap(sha1, localGame, safeMapName); - } + // Check if the parameter's contain spaces. + // The presence of spaces indicates a user-specified map name. + int firstSpaceIndex = parameters.IndexOf(' ', StringComparison.OrdinalIgnoreCase); - #endregion + if (firstSpaceIndex == -1) + { + // The user did not supply a map name. + sha1 = parameters; + mapName = "user_chat_command_download"; + } + else + { + // User supplied a map name. + sha1 = parameters[..firstSpaceIndex]; + mapName = parameters[(firstSpaceIndex + 1)..]; + mapName = mapName.Trim(); + } - #region Game broadcasting logic + // Remove erroneous "?". These sneak in when someone double-clicks a map ID and copies it from the cncnet search endpoint. + // There is some weird whitespace that gets copied to chat as a "?" at the end of the hash. It's hard to spot, so just hold the user's hand. + sha1 = sha1.Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase); - /// - /// Lowers the time until the next game broadcasting message. - /// - private void AccelerateGameBroadcasting() => - gameBroadcastTimer.Accelerate(TimeSpan.FromSeconds(GAME_BROADCAST_ACCELERATION)); + // See if the user already has this map, with any filename, before attempting to download it. + GameModeMap loadedMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == sha1); - private void BroadcastGame() + if (loadedMap != null) { - Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); + message = string.Format( + CultureInfo.CurrentCulture, + "The map for ID \"{0}\" is already loaded from \"{1}.map\", delete the existing file before trying again.".L10N("Client:Main:DownloadMapCommandSha1AlreadyExists"), + sha1, + loadedMap.Map.BaseFilePath); + AddNotice(message, Color.Yellow); + Logger.Log(message); + return; + } - if (broadcastChannel == null) - return; + // Replace any characters that are not safe for filenames. + char replaceUnsafeCharactersWith = '-'; - if (ProgramConstants.IsInGame && broadcastChannel.Users.Count > 500) - return; + // Use a hashset instead of an array for quick lookups in `invalidChars.Contains()`. + var invalidChars = new HashSet(Path.GetInvalidFileNameChars()); + string safeMapName = new(mapName.Select(c => invalidChars.Contains(c) ? replaceUnsafeCharactersWith : c).ToArray()); - if (GameMode == null || Map == null) - return; + chatCommandDownloadedMaps.Add(sha1); - StringBuilder sb = new StringBuilder("GAME "); - sb.Append(ProgramConstants.CNCNET_PROTOCOL_REVISION); - sb.Append(";"); - sb.Append(ProgramConstants.GAME_VERSION); - sb.Append(";"); - sb.Append(playerLimit); - sb.Append(";"); - sb.Append(channel.ChannelName); - sb.Append(";"); - sb.Append(channel.UIName); - sb.Append(";"); - if (Locked) - sb.Append("1"); - else - sb.Append("0"); - sb.Append(Convert.ToInt32(isCustomPassword)); - sb.Append(Convert.ToInt32(closed)); - sb.Append("0"); // IsLoadedGame - sb.Append("0"); // IsLadder - sb.Append(";"); - foreach (PlayerInfo pInfo in Players) - { - sb.Append(pInfo.Name); - sb.Append(","); - } + message = string.Format( + CultureInfo.CurrentCulture, + "Attempting to download map via chat command: sha1={0}, mapName={1}".L10N("Client:Main:DownloadMapCommandStartingDownload"), + sha1, + mapName); - sb.Remove(sb.Length - 1, 1); - sb.Append(";"); - sb.Append(Map.UntranslatedName); - sb.Append(";"); - sb.Append(GameMode.UntranslatedUIName); - sb.Append(";"); - sb.Append(tunnelHandler.CurrentTunnel.Address + ":" + tunnelHandler.CurrentTunnel.Port); - sb.Append(";"); - sb.Append(0); // LoadedGameId + Logger.Log(message); + AddNotice(message); - broadcastChannel.SendCTCPMessage(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 20); - } + MapSharer.DownloadMap(sha1, localGame, safeMapName); + } - #endregion + /// + /// Lowers the time until the next game broadcasting message. + /// + private void AccelerateGameBroadcasting() => + gameBroadcastTimer.Accelerate(TimeSpan.FromSeconds(GAME_BROADCAST_ACCELERATION)); - public override string GetSwitchName() => "Game Lobby".L10N("Client:Main:GameLobby"); + private async ValueTask BroadcastGameAsync() + { + Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); + + if (broadcastChannel == null) + return; + + if (ProgramConstants.IsInGame && broadcastChannel.Users.Count > 500) + return; + + if (GameMode == null || Map == null) + return; + + StringBuilder sb = new StringBuilder(CnCNetCommands.GAME + " ") + .Append(ProgramConstants.CNCNET_PROTOCOL_REVISION) + .Append(';') + .Append(ProgramConstants.GAME_VERSION) + .Append(';') + .Append(playerLimit) + .Append(';') + .Append(channel.ChannelName) + .Append(';') + .Append(channel.UIName) + .Append(';') + .Append(Locked ? '1' : '0') + .Append(Convert.ToInt32(isCustomPassword)) + .Append(Convert.ToInt32(closed)) + .Append('0') // IsLoadedGame + .Append('0') // IsLadder + .Append(';'); + + foreach (PlayerInfo pInfo in Players) + { + sb.Append(pInfo.Name) + .Append(','); + } + + sb.Remove(sb.Length - 1, 1) + .Append(';') + .Append(Map.UntranslatedName) + .Append(';') + .Append(GameMode.UntranslatedUIName) + .Append(';') + .Append(tunnelHandler.CurrentTunnel?.Hash ?? ProgramConstants.CNCNET_DYNAMIC_TUNNELS) + .Append(';') + .Append(0); // LoadedGameId + await broadcastChannel.SendCTCPMessageAsync(sb.ToString(), QueuedMessageType.SYSTEM_MESSAGE, 20).ConfigureAwait(false); } -} + + public override string GetSwitchName() => "Game Lobby".L10N("Client:Main:GameLobby"); +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntCommandHandler.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntCommandHandler.cs index be6e38fc4..736be0d30 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntCommandHandler.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntCommandHandler.cs @@ -19,7 +19,7 @@ public override bool Handle(string sender, string message) if (message.StartsWith(CommandName)) { int value; - bool success = int.TryParse(message.Substring(CommandName.Length + 1), out value); + bool success = int.TryParse(message[(CommandName.Length + 1)..], out value); if (success) { diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntNotificationHandler.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntNotificationHandler.cs index 5815b9375..a96b919b6 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntNotificationHandler.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/IntNotificationHandler.cs @@ -18,7 +18,7 @@ public override bool Handle(string sender, string message) { if (message.StartsWith(CommandName)) { - string intPart = message.Substring(CommandName.Length + 1); + string intPart = message[(CommandName.Length + 1)..]; int value; bool success = int.TryParse(intPart, out value); diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/StringCommandHandler.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/StringCommandHandler.cs index e1403ab15..2b7d92610 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/StringCommandHandler.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CommandHandlers/StringCommandHandler.cs @@ -16,9 +16,9 @@ public override bool Handle(string sender, string message) if (message.Length < CommandName.Length + 1) return false; - if (message.StartsWith(CommandName)) + if (message.StartsWith(CommandName + " ")) { - string parameters = message.Substring(CommandName.Length + 1); + string parameters = message[(CommandName.Length + 1)..]; commandHandler.Invoke(sender, parameters); //commandHandler(sender, message.Substring(CommandName.Length + 1)); diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs index f8fb4d7dd..a6badf4e6 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs @@ -12,7 +12,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; +using System.Threading.Tasks; using ClientCore.Enums; +using ClientCore.Extensions; using DTAClient.DXGUI.Multiplayer.CnCNet; using DTAClient.Online.EventArguments; using ClientCore.Extensions; @@ -24,7 +27,7 @@ namespace DTAClient.DXGUI.Multiplayer.GameLobby /// A generic base for all game lobbies (Skirmish, LAN and CnCNet). /// Contains the common logic for parsing game options and handling player info. /// - public abstract class GameLobbyBase : INItializableWindow + internal abstract class GameLobbyBase : INItializableWindow { protected const int MAX_PLAYER_COUNT = 8; protected const int PLAYER_OPTION_VERTICAL_MARGIN = 12; @@ -55,8 +58,8 @@ public GameLobbyBase( string iniName, MapLoader mapLoader, bool isMultiplayer, - DiscordHandler discordHandler - ) : base(windowManager) + DiscordHandler discordHandler) + : base(windowManager) { _iniSectionName = iniName; MapLoader = mapLoader; @@ -137,7 +140,7 @@ protected GameModeMap GameModeMap protected List Players = new List(); protected List AIPlayers = new List(); - protected virtual PlayerInfo FindLocalPlayer() => Players.Find(p => p.Name == ProgramConstants.PLAYERNAME); + protected PlayerInfo FindLocalPlayer() => Players.Find(p => ProgramConstants.PLAYERNAME.Equals(p.Name, StringComparison.OrdinalIgnoreCase)); protected bool PlayerUpdatingInProgress { get; set; } @@ -157,11 +160,11 @@ protected GameModeMap GameModeMap protected List RandomSelectors = new List(); - private readonly bool isMultiplayer = false; + private readonly bool isMultiplayer; private MatchStatistics matchStatistics; - private bool disableGameOptionUpdateBroadcast = false; + private bool disableGameOptionUpdateBroadcast; protected EventHandler MultiplayerNameRightClicked; @@ -169,7 +172,7 @@ protected GameModeMap GameModeMap /// If set, the client will remove all starting waypoints from the map /// before launching it. /// - protected bool RemoveStartingLocations { get; set; } = false; + protected bool RemoveStartingLocations { get; set; } protected IniFile GameOptionsIni { get; private set; } protected XNAClientButton BtnSaveLoadGameOptions { get; set; } @@ -178,12 +181,11 @@ protected GameModeMap GameModeMap private LoadOrSaveGameOptionPresetWindow loadOrSaveGameOptionPresetWindow; + private EventHandler lbGameModeMapList_SelectedIndexChangedFunc; + public override void Initialize() { Name = _iniSectionName; - //if (WindowManager.RenderResolutionY < 800) - // ClientRectangle = new Rectangle(0, 0, WindowManager.RenderResolutionX, WindowManager.RenderResolutionY); - //else ClientRectangle = new Rectangle(0, 0, WindowManager.RenderResolutionX - 60, WindowManager.RenderResolutionY - 32); WindowManager.CenterControlOnScreen(this); BackgroundTexture = AssetLoader.LoadTexture("gamelobbybg.png"); @@ -205,10 +207,10 @@ public override void Initialize() PlayerOptionsPanel = FindChild(nameof(PlayerOptionsPanel)); btnLeaveGame = FindChild(nameof(btnLeaveGame)); - btnLeaveGame.LeftClick += BtnLeaveGame_LeftClick; + btnLeaveGame.LeftClick += (_, _) => BtnLeaveGame_LeftClickAsync().HandleTask(); btnLaunchGame = FindChild(nameof(btnLaunchGame)); - btnLaunchGame.LeftClick += BtnLaunchGame_LeftClick; + btnLaunchGame.LeftClick += (_, _) => BtnLaunchGame_LeftClickAsync().HandleTask(); btnLaunchGame.InitStarDisplay(RankTextures); MapPreviewBox = FindChild("MapPreviewBox"); @@ -221,7 +223,7 @@ public override void Initialize() lblMapSize = FindChild(nameof(lblMapSize)); lbGameModeMapList = FindChild("lbMapList"); // lbMapList for backwards compatibility - lbGameModeMapList.SelectedIndexChanged += LbGameModeMapList_SelectedIndexChanged; + lbGameModeMapList.SelectedIndexChanged += lbGameModeMapList_SelectedIndexChangedFunc; lbGameModeMapList.RightClick += LbGameModeMapList_RightClick; lbGameModeMapList.AllowKeyboardInput = true; //!isMultiplayer @@ -250,8 +252,7 @@ public override void Initialize() XNAPanel rankHeader = new XNAPanel(WindowManager); rankHeader.BackgroundTexture = AssetLoader.LoadTexture("rank.png"); - rankHeader.ClientRectangle = new Rectangle(0, 0, rankHeader.BackgroundTexture.Width, - 19); + rankHeader.ClientRectangle = new Rectangle(0, 0, rankHeader.BackgroundTexture.Width, 19); XNAListBox rankListBox = new XNAListBox(WindowManager); rankListBox.TextBorderDistance = 2; @@ -260,7 +261,7 @@ public override void Initialize() lbGameModeMapList.AddColumn("MAP NAME".L10N("Client:Main:MapNameHeader"), lbGameModeMapList.Width - RankTextures[1].Width - 3); ddGameModeMapFilter = FindChild("ddGameMode"); // ddGameMode for backwards compatibility - ddGameModeMapFilter.SelectedIndexChanged += DdGameModeMapFilter_SelectedIndexChanged; + ddGameModeMapFilter.SelectedIndexChanged += (_, _) => DdGameModeMapFilter_SelectedIndexChangedAsync().HandleTask(); ddGameModeMapFilter.AddItem(CreateGameFilterItem(FavoriteMapsLabel, new GameModeMapFilter(GetFavoriteGameModeMaps))); foreach (GameMode gm in GameModeMaps.GameModes) @@ -274,10 +275,12 @@ public override void Initialize() tbMapSearch.InputReceived += TbMapSearch_InputReceived; btnPickRandomMap = FindChild(nameof(btnPickRandomMap)); - btnPickRandomMap.LeftClick += BtnPickRandomMap_LeftClick; + btnPickRandomMap.LeftClick += (_, _) => PickRandomMapAsync().HandleTask(); + + CheckBoxes.ForEach(chk => chk.CheckedChanged += (sender, _) => ChkBox_CheckedChangedAsync(sender).HandleTask()); + DropDowns.ForEach(dd => dd.SelectedIndexChanged += (sender, _) => Dropdown_SelectedIndexChangedAsync(sender).HandleTask()); - CheckBoxes.ForEach(chk => chk.CheckedChanged += ChkBox_CheckedChanged); - DropDowns.ForEach(dd => dd.SelectedIndexChanged += Dropdown_SelectedIndexChanged); + lbGameModeMapList_SelectedIndexChangedFunc = (_, _) => LbGameModeMapList_SelectedIndexChangedAsync().HandleTask(); InitializeGameOptionPresetUI(); } @@ -296,8 +299,7 @@ private void InitBtnMapSort() btnMapSortAlphabetically.Name = nameof(btnMapSortAlphabetically); btnMapSortAlphabetically.ClientRectangle = new Rectangle( ddGameModeMapFilter.X + -ddGameModeMapFilter.Height - 4, ddGameModeMapFilter.Y, - ddGameModeMapFilter.Height, ddGameModeMapFilter.Height - ); + ddGameModeMapFilter.Height, ddGameModeMapFilter.Height); btnMapSortAlphabetically.LeftClick += BtnMapSortAlphabetically_LeftClick; btnMapSortAlphabetically.SetToolTipText("Sort Maps Alphabetically".L10N("Client:Main:MapSortAlphabeticallyToolTip")); RefreshMapSortAlphabeticallyBtn(); @@ -396,11 +398,11 @@ protected void HandleGameOptionPresetSaveCommand(string presetName) AddNotice(error); } - protected void HandleGameOptionPresetLoadCommand(GameOptionPresetEventArgs e) => HandleGameOptionPresetLoadCommand(e.PresetName); + protected void HandleGameOptionPresetLoadCommand(GameOptionPresetEventArgs e) => HandleGameOptionPresetLoadCommandAsync(e.PresetName).HandleTask(); - protected void HandleGameOptionPresetLoadCommand(string presetName) + protected async ValueTask HandleGameOptionPresetLoadCommandAsync(string presetName) { - if (LoadGameOptionPreset(presetName)) + if (await LoadGameOptionPresetAsync(presetName).ConfigureAwait(false)) AddNotice("Game option preset loaded succesfully.".L10N("Client:Main:PresetLoaded")); else AddNotice(string.Format("Preset {0} not found!".L10N("Client:Main:PresetNotFound"), presetName)); @@ -410,38 +412,38 @@ protected void HandleGameOptionPresetLoadCommand(string presetName) protected abstract void AddNotice(string message, Color color); - private void BtnPickRandomMap_LeftClick(object sender, EventArgs e) => PickRandomMap(); - private void TbMapSearch_InputReceived(object sender, EventArgs e) => ListMaps(); - private void Dropdown_SelectedIndexChanged(object sender, EventArgs e) + private async ValueTask Dropdown_SelectedIndexChangedAsync(object sender) { if (disableGameOptionUpdateBroadcast) return; var dd = (GameLobbyDropDown)sender; dd.HostSelectedIndex = dd.SelectedIndex; - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); } - private void ChkBox_CheckedChanged(object sender, EventArgs e) + private async ValueTask ChkBox_CheckedChangedAsync(object sender) { if (disableGameOptionUpdateBroadcast) return; var checkBox = (GameLobbyCheckBox)sender; checkBox.HostChecked = checkBox.Checked; - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); } - protected virtual void OnGameOptionChanged() + protected virtual ValueTask OnGameOptionChangedAsync() { CheckDisallowedSides(); btnLaunchGame.SetRank(GetRank()); + + return ValueTask.CompletedTask; } - protected void DdGameModeMapFilter_SelectedIndexChanged(object sender, EventArgs e) + protected async ValueTask DdGameModeMapFilter_SelectedIndexChangedAsync() { gameModeMapFilter = ddGameModeMapFilter.SelectedItem.Tag as GameModeMapFilter; @@ -453,10 +455,10 @@ protected void DdGameModeMapFilter_SelectedIndexChanged(object sender, EventArgs if (lbGameModeMapList.SelectedIndex == -1) lbGameModeMapList.SelectedIndex = 0; // Select default GameModeMap else - ChangeMap(GameModeMap); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); } - protected void BtnPlayerExtraOptions_LeftClick(object sender, EventArgs e) + private void BtnPlayerExtraOptions_LeftClick(object sender, EventArgs e) { if (PlayerExtraOptionsPanel.Enabled) PlayerExtraOptionsPanel.Disable(); @@ -515,7 +517,7 @@ private List GetSortedGameModeMaps() protected void ListMaps() { - lbGameModeMapList.SelectedIndexChanged -= LbGameModeMapList_SelectedIndexChanged; + lbGameModeMapList.SelectedIndexChanged -= lbGameModeMapList_SelectedIndexChangedFunc; lbGameModeMapList.ClearItems(); lbGameModeMapList.SetTopIndex(0); @@ -584,7 +586,7 @@ protected void ListMaps() lbGameModeMapList.TopIndex++; } - lbGameModeMapList.SelectedIndexChanged += LbGameModeMapList_SelectedIndexChanged; + lbGameModeMapList.SelectedIndexChanged += lbGameModeMapList_SelectedIndexChangedFunc; } protected abstract int GetDefaultMapRankIndex(GameModeMap gameModeMap); @@ -616,7 +618,7 @@ private void DeleteMapConfirmation() var messageBox = XNAMessageBox.ShowYesNoDialog(WindowManager, "Delete Confirmation".L10N("Client:Main:DeleteMapConfirmTitle"), string.Format("Are you sure you wish to delete the custom map {0}?".L10N("Client:Main:DeleteMapConfirmText"), Map.Name)); - messageBox.YesClickedAction = DeleteSelectedMap; + messageBox.YesClickedAction = _ => DeleteSelectedMapAsync().HandleTask(); } private void MapPreviewBox_ToggleFavorite(object sender, EventArgs e) => @@ -641,7 +643,7 @@ protected void RefreshForFavoriteMapRemoved() lbGameModeMapList.SelectedIndex = 0; // the map was removed while viewing favorites } - private void DeleteSelectedMap(XNAMessageBox messageBox) + private async ValueTask DeleteSelectedMapAsync() { try { @@ -660,21 +662,21 @@ private void DeleteSelectedMap(XNAMessageBox messageBox) } ListMaps(); - ChangeMap(GameModeMap); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); } catch (IOException ex) { - Logger.Log($"Deleting map {Map.BaseFilePath} failed! Message: {ex.Message}"); + ProgramConstants.LogException(ex, $"Deleting map {Map.BaseFilePath} failed!"); XNAMessageBox.Show(WindowManager, "Deleting Map Failed".L10N("Client:Main:DeleteMapFailedTitle"), "Deleting map failed! Reason:".L10N("Client:Main:DeleteMapFailedText") + " " + ex.Message); } } - private void LbGameModeMapList_SelectedIndexChanged(object sender, EventArgs e) + private async ValueTask LbGameModeMapList_SelectedIndexChangedAsync() { if (lbGameModeMapList.SelectedIndex < 0 || lbGameModeMapList.SelectedIndex >= lbGameModeMapList.ItemCount) { - ChangeMap(null); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); return; } @@ -682,7 +684,7 @@ private void LbGameModeMapList_SelectedIndexChanged(object sender, EventArgs e) GameModeMap = (GameModeMap)item.Tag; - ChangeMap(GameModeMap); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); } private void LbGameModeMapList_HoveredIndexChanged(object sender, EventArgs e) @@ -701,7 +703,7 @@ private void LbGameModeMapList_HoveredIndexChanged(object sender, EventArgs e) mapListTooltip.Text = string.Empty; } - private void PickRandomMap() + private async ValueTask PickRandomMapAsync() { int totalPlayerCount = Players.Count(p => p.SideId < ddPlayerSides[0].Items.Count - 1) + AIPlayers.Count; @@ -714,7 +716,7 @@ private void PickRandomMap() Logger.Log("PickRandomMap: Rolled " + random + " out of " + maps.Count + ". Picked map: " + Map.Name); - ChangeMap(GameModeMap); + await ChangeMapAsync(GameModeMap).ConfigureAwait(false); tbMapSearch.Text = string.Empty; tbMapSearch.OnSelectedChanged(); ListMaps(); @@ -725,15 +727,15 @@ private List GetMapList(int playerCount) List mapList = (GameMode?.Maps.Where(x => x.MaxPlayers == playerCount) ?? Array.Empty()).ToList(); if (mapList.Count < 1 && playerCount <= MAX_PLAYER_COUNT) return GetMapList(playerCount + 1); - else - return mapList; + + return mapList; } /// /// Refreshes the map selection UI to match the currently selected map /// and game mode. /// - protected void RefreshMapSelectionUI() + protected async ValueTask RefreshMapSelectionUIAsync() { if (GameMode == null) return; @@ -744,12 +746,12 @@ protected void RefreshMapSelectionUI() return; if (ddGameModeMapFilter.SelectedIndex == gameModeMapFilterIndex) - DdGameModeMapFilter_SelectedIndexChanged(this, EventArgs.Empty); + await DdGameModeMapFilter_SelectedIndexChangedAsync().ConfigureAwait(false); ddGameModeMapFilter.SelectedIndex = gameModeMapFilterIndex; } - protected void AddSideToDropDown(XNADropDown dd, string name, string? uiName = null, Texture2D? texture = null) + protected void AddSideToDropDown(XNADropDown dd, string name, string uiName = null, Texture2D texture = null) { XNADropDownItem item = new() { @@ -781,9 +783,6 @@ protected void InitPlayerOptionDropdowns() int teamWidth = ConfigIni.GetIntValue(Name, "TeamWidth", 46); int locationX = ConfigIni.GetIntValue(Name, "PlayerOptionLocationX", 25); int locationY = ConfigIni.GetIntValue(Name, "PlayerOptionLocationY", 24); - - // InitPlayerOptionDropdowns(136, 91, 79, 49, 46, new Point(25, 24)); - string[] sides = ClientConfiguration.Instance.Sides.Split(',').ToArray(); SideCount = sides.Length; @@ -804,7 +803,7 @@ protected void InitPlayerOptionDropdowns() ddPlayerName.AddItem(String.Empty); ProgramConstants.AI_PLAYER_NAMES.ForEach(ddPlayerName.AddItem); ddPlayerName.AllowDropDown = true; - ddPlayerName.SelectedIndexChanged += CopyPlayerDataFromUI; + ddPlayerName.SelectedIndexChanged += (sender, _) => CopyPlayerDataFromUIAsync(sender).HandleTask(); ddPlayerName.RightClick += MultiplayerName_RightClick; ddPlayerName.Tag = true; @@ -823,7 +822,7 @@ protected void InitPlayerOptionDropdowns() AddSideToDropDown(ddPlayerSide, sideName); ddPlayerSide.AllowDropDown = false; - ddPlayerSide.SelectedIndexChanged += CopyPlayerDataFromUI; + ddPlayerSide.SelectedIndexChanged += (sender, _) => CopyPlayerDataFromUIAsync(sender).HandleTask(); ddPlayerSide.Tag = true; var ddPlayerColor = new XNAClientDropDown(WindowManager); @@ -835,7 +834,7 @@ protected void InitPlayerOptionDropdowns() foreach (MultiplayerColor mpColor in MPColors) ddPlayerColor.AddItem(mpColor.Name, mpColor.XnaColor); ddPlayerColor.AllowDropDown = false; - ddPlayerColor.SelectedIndexChanged += CopyPlayerDataFromUI; + ddPlayerColor.SelectedIndexChanged += (sender, _) => CopyPlayerDataFromUIAsync(sender).HandleTask(); ddPlayerColor.Tag = false; var ddPlayerTeam = new XNAClientDropDown(WindowManager); @@ -846,7 +845,7 @@ protected void InitPlayerOptionDropdowns() ddPlayerTeam.AddItem("-"); ProgramConstants.TEAMS.ForEach(ddPlayerTeam.AddItem); ddPlayerTeam.AllowDropDown = false; - ddPlayerTeam.SelectedIndexChanged += CopyPlayerDataFromUI; + ddPlayerTeam.SelectedIndexChanged += (sender, _) => CopyPlayerDataFromUIAsync(sender).HandleTask(); ddPlayerTeam.Tag = true; var ddPlayerStart = new XNAClientDropDown(WindowManager); @@ -857,7 +856,7 @@ protected void InitPlayerOptionDropdowns() for (int j = 1; j < 9; j++) ddPlayerStart.AddItem(j.ToString()); ddPlayerStart.AllowDropDown = false; - ddPlayerStart.SelectedIndexChanged += CopyPlayerDataFromUI; + ddPlayerStart.SelectedIndexChanged += (sender, _) => CopyPlayerDataFromUIAsync(sender).HandleTask(); ddPlayerStart.Visible = false; ddPlayerStart.Enabled = false; ddPlayerStart.Tag = true; @@ -901,7 +900,7 @@ protected void InitPlayerOptionDropdowns() { PlayerExtraOptionsPanel = FindChild(nameof(PlayerExtraOptionsPanel)); PlayerExtraOptionsPanel.Disable(); - PlayerExtraOptionsPanel.OptionsChanged += PlayerExtraOptions_OptionsChanged; + PlayerExtraOptionsPanel.OptionsChanged += (_, _) => PlayerExtraOptions_OptionsChangedAsync().HandleTask(); btnPlayerExtraOptionsOpen.LeftClick += BtnPlayerExtraOptions_LeftClick; } @@ -920,7 +919,7 @@ private XNALabel GeneratePlayerOptionCaption(string name, string text, int x, in return label; } - protected virtual void PlayerExtraOptions_OptionsChanged(object sender, EventArgs e) + protected virtual ValueTask PlayerExtraOptions_OptionsChangedAsync() { var playerExtraOptions = GetPlayerExtraOptions(); @@ -938,13 +937,17 @@ protected virtual void PlayerExtraOptions_OptionsChanged(object sender, EventArg UpdateMapPreviewBoxEnabledStatus(); RefreshBtnPlayerExtraOptionsOpenTexture(); + + return ValueTask.CompletedTask; } private void EnablePlayerOptionDropDown(XNAClientDropDown clientDropDown, int playerIndex, bool enable) { var pInfo = GetPlayerInfoForIndex(playerIndex); var allowOtherPlayerOptionsChange = AllowPlayerOptionsChange() && pInfo != null; + clientDropDown.AllowDropDown = enable && (allowOtherPlayerOptionsChange || pInfo?.Name == ProgramConstants.PLAYERNAME); + if (!clientDropDown.AllowDropDown) clientDropDown.SelectedIndex = clientDropDown.SelectedIndex > 0 ? 0 : clientDropDown.SelectedIndex; } @@ -991,7 +994,10 @@ private void GetRandomSelectors(List selectorNames, List selector randomSides = Array.ConvertAll(tmp, int.Parse).Distinct().ToList(); randomSides.RemoveAll(x => (x >= SideCount || x < 0)); } - catch (FormatException) { } + catch (FormatException ex) + { + ProgramConstants.LogException(ex); + } if (randomSides.Count > 1) { @@ -1001,9 +1007,9 @@ private void GetRandomSelectors(List selectorNames, List selector } } - protected abstract void BtnLaunchGame_LeftClick(object sender, EventArgs e); + protected abstract ValueTask BtnLaunchGame_LeftClickAsync(); - protected abstract void BtnLeaveGame_LeftClick(object sender, EventArgs e); + protected abstract ValueTask BtnLeaveGame_LeftClickAsync(); /// /// Updates Discord Rich Presence with actual information. @@ -1284,7 +1290,7 @@ protected virtual PlayerHouseInfo[] Randomize(List teamStartMa /// private PlayerHouseInfo[] WriteSpawnIni() { - Logger.Log("Writing spawn.ini"); + Logger.Log($"Writing {ProgramConstants.SPAWNER_SETTINGS}"); FileInfo spawnerSettingsFile = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS); @@ -1300,15 +1306,12 @@ private PlayerHouseInfo[] WriteSpawnIni() } var teamStartMappings = new List(0); + if (PlayerExtraOptionsPanel != null) - { teamStartMappings = PlayerExtraOptionsPanel.GetTeamStartMappings(); - } PlayerHouseInfo[] houseInfos = Randomize(teamStartMappings); - IniFile spawnIni = new IniFile(spawnerSettingsFile.FullName); - IniSection settings = new IniSection("Settings"); settings.SetStringValue("Name", ProgramConstants.PLAYERNAME); @@ -1321,17 +1324,22 @@ private PlayerHouseInfo[] WriteSpawnIni() settings.SetStringValue("MapID", Map.BaseFilePath); settings.SetIntValue("PlayerCount", Players.Count); - int myIndex = Players.FindIndex(c => c.Name == ProgramConstants.PLAYERNAME); + + int myIndex = Players.FindIndex(c => c == FindLocalPlayer()); + settings.SetIntValue("Side", houseInfos[myIndex].InternalSideIndex); settings.SetBooleanValue("IsSpectator", houseInfos[myIndex].IsSpectator); settings.SetIntValue("Color", houseInfos[myIndex].ColorIndex); settings.SetStringValue("CustomLoadScreen", LoadingScreenController.GetLoadScreenName(houseInfos[myIndex].InternalSideIndex.ToString())); settings.SetIntValue("AIPlayers", AIPlayers.Count); settings.SetIntValue("Seed", RandomSeed); + if (GetPvPTeamCount() > 1) settings.SetBooleanValue("CoachMode", true); + if (GetGameType() == GameType.Coop) settings.SetBooleanValue("AutoSurrender", false); + spawnIni.AddSection(settings); WriteSpawnIniAdditions(spawnIni); @@ -1342,24 +1350,20 @@ private PlayerHouseInfo[] WriteSpawnIni() dd.ApplySpawnIniCode(spawnIni); // Apply forced options from GameOptions.ini - List forcedKeys = GameOptionsIni.GetSectionKeys("ForcedSpawnIniOptions"); if (forcedKeys != null) { foreach (string key in forcedKeys) { - spawnIni.SetStringValue("Settings", key, - GameOptionsIni.GetStringValue("ForcedSpawnIniOptions", key, String.Empty)); + spawnIni.SetStringValue("Settings", key, GameOptionsIni.GetStringValue("ForcedSpawnIniOptions", key, String.Empty)); } } GameMode.ApplySpawnIniCode(spawnIni); // Forced options from the game mode - Map.ApplySpawnIniCode(spawnIni, Players.Count + AIPlayers.Count, - AIPlayers.Count, GameMode.CoopDifficultyLevel); // Forced options from the map + Map.ApplySpawnIniCode(spawnIni, Players.Count + AIPlayers.Count, AIPlayers.Count, GameMode.CoopDifficultyLevel); // Forced options from the map // Player options - int otherId = 1; for (int pId = 0; pId < Players.Count; pId++) @@ -1367,7 +1371,7 @@ private PlayerHouseInfo[] WriteSpawnIni() PlayerInfo pInfo = Players[pId]; PlayerHouseInfo pHouseInfo = houseInfos[pId]; - if (pInfo.Name == ProgramConstants.PLAYERNAME) + if (pInfo == FindLocalPlayer()) continue; string sectionName = "Other" + otherId; @@ -1376,7 +1380,7 @@ private PlayerHouseInfo[] WriteSpawnIni() spawnIni.SetIntValue(sectionName, "Side", pHouseInfo.InternalSideIndex); spawnIni.SetBooleanValue(sectionName, "IsSpectator", pHouseInfo.IsSpectator); spawnIni.SetIntValue(sectionName, "Color", pHouseInfo.ColorIndex); - spawnIni.SetStringValue(sectionName, "Ip", GetIPAddressForPlayer(pInfo)); + spawnIni.SetStringValue(sectionName, "Ip", GetIPAddressForPlayer(pInfo).ToString()); spawnIni.SetIntValue(sectionName, "Port", pInfo.Port); otherId++; @@ -1400,7 +1404,6 @@ private PlayerHouseInfo[] WriteSpawnIni() for (int aiId = 0; aiId < AIPlayers.Count; aiId++) { int multiId = multiCmbIndexes.Count + aiId + 1; - string keyName = "Multi" + multiId; spawnIni.SetIntValue("HouseHandicaps", keyName, AIPlayers[aiId].HouseHandicapAILevel); @@ -1412,6 +1415,7 @@ private PlayerHouseInfo[] WriteSpawnIni() for (int multiId = 0; multiId < multiCmbIndexes.Count; multiId++) { int pIndex = multiCmbIndexes[multiId]; + if (houseInfos[pIndex].IsSpectator) spawnIni.SetBooleanValue("IsSpectator", "Multi" + (multiId + 1), true); } @@ -1428,8 +1432,8 @@ private PlayerHouseInfo[] WriteSpawnIni() if (startingWaypoint > -1) { int multiIndex = pId + 1; - spawnIni.SetIntValue("SpawnLocations", "Multi" + multiIndex, - startingWaypoint); + + spawnIni.SetIntValue("SpawnLocations", "Multi" + multiIndex, startingWaypoint); } } @@ -1440,8 +1444,8 @@ private PlayerHouseInfo[] WriteSpawnIni() if (startingWaypoint > -1) { int multiIndex = Players.Count + aiId + 1; - spawnIni.SetIntValue("SpawnLocations", "Multi" + multiIndex, - startingWaypoint); + + spawnIni.SetIntValue("SpawnLocations", "Multi" + multiIndex, startingWaypoint); } } @@ -1489,7 +1493,10 @@ protected bool IsPlayerSpectator(PlayerInfo pInfo) return false; } - protected virtual string GetIPAddressForPlayer(PlayerInfo player) => "0.0.0.0"; + /// + /// Returns the IPv4 address used to connect to the local game. + /// + protected virtual IPAddress GetIPAddressForPlayer(PlayerInfo player) => IPAddress.Any; /// /// Override this in a derived class to write game lobby specific code to @@ -1523,7 +1530,7 @@ private void InitializeMatchStatistics(PlayerHouseInfo[] houseInfos) for (int pId = 0; pId < Players.Count; pId++) { PlayerInfo pInfo = Players[pId]; - matchStatistics.AddPlayer(pInfo.Name, pInfo.Name == ProgramConstants.PLAYERNAME, + matchStatistics.AddPlayer(pInfo.Name, pInfo == FindLocalPlayer(), false, pInfo.SideId == SideCount + RandomSelectorCount, houseInfos[pId].SideIndex + 1, pInfo.TeamId, MPColors.FindIndex(c => c.GameColorIndex == houseInfos[pId].ColorIndex), 10); } @@ -1772,7 +1779,7 @@ private void ManipulateStartingLocations(IniFile mapIni, PlayerHouseInfo[] house /// Writes spawn.ini, writes the map file, initializes statistics and /// starts the game process. /// - protected virtual void StartGame() + protected virtual async ValueTask StartGameAsync() { PlayerHouseInfo[] houseInfos = WriteSpawnIni(); InitializeMatchStatistics(houseInfos); @@ -1780,36 +1787,40 @@ protected virtual void StartGame() GameProcessLogic.GameProcessExited += GameProcessExited_Callback; - GameProcessLogic.StartGameProcess(WindowManager); + await GameProcessLogic.StartGameProcessAsync(WindowManager).ConfigureAwait(false); UpdateDiscordPresence(true); } - private void GameProcessExited_Callback() => AddCallback(new Action(GameProcessExited), null); + private void GameProcessExited_Callback() => AddCallback(() => GameProcessExitedAsync().HandleTask()); - protected virtual void GameProcessExited() + protected virtual ValueTask GameProcessExitedAsync() { GameProcessLogic.GameProcessExited -= GameProcessExited_Callback; - Logger.Log("GameProcessExited: Parsing statistics."); - - matchStatistics.ParseStatistics(ProgramConstants.GamePath, ClientConfiguration.Instance.LocalGame, false); - - Logger.Log("GameProcessExited: Adding match to statistics."); - - StatisticsManager.Instance.AddMatchAndSaveDatabase(true, matchStatistics); - + ParseStatisticsAsync().HandleTask(); ClearReadyStatuses(); - CopyPlayerDataToUI(); - UpdateDiscordPresence(true); + + return ValueTask.CompletedTask; + } + + private async ValueTask ParseStatisticsAsync() + { + if (matchStatistics is not null) + { + Logger.Log("GameProcessExited: Parsing statistics."); + await matchStatistics.ParseStatisticsAsync(ProgramConstants.GamePath, false).ConfigureAwait(false); + Logger.Log("GameProcessExited: Adding match to statistics."); + await StatisticsManager.Instance.AddMatchAndSaveDatabaseAsync(true, matchStatistics).ConfigureAwait(false); + } } /// /// "Copies" player information from the UI to internal memory, /// applying users' player options changes. /// - protected virtual void CopyPlayerDataFromUI(object sender, EventArgs e) + protected virtual async ValueTask CopyPlayerDataFromUIAsync(object sender) { if (PlayerUpdatingInProgress) return; @@ -1818,7 +1829,7 @@ protected virtual void CopyPlayerDataFromUI(object sender, EventArgs e) if ((bool)senderDropDown.Tag) ClearReadyStatuses(); - var oldSideId = Players.Find(p => p.Name == ProgramConstants.PLAYERNAME)?.SideId; + var oldSideId = FindLocalPlayer()?.SideId; for (int pId = 0; pId < Players.Count; pId++) { @@ -1842,10 +1853,10 @@ protected virtual void CopyPlayerDataFromUI(object sender, EventArgs e) ddName.SelectedIndex = 0; break; case 2: - KickPlayer(pId); + await KickPlayerAsync(pId).ConfigureAwait(false); break; case 3: - BanPlayer(pId); + await BanPlayerAsync(pId).ConfigureAwait(false); break; } } @@ -1876,7 +1887,7 @@ protected virtual void CopyPlayerDataFromUI(object sender, EventArgs e) CopyPlayerDataToUI(); btnLaunchGame.SetRank(GetRank()); - if (oldSideId != Players.Find(p => p.Name == ProgramConstants.PLAYERNAME)?.SideId) + if (oldSideId != FindLocalPlayer()?.SideId) UpdateDiscordPresence(); } @@ -1940,7 +1951,7 @@ protected virtual void CopyPlayerDataToUI() ddPlayerName.SelectedIndex = 0; ddPlayerName.AllowDropDown = false; - bool allowPlayerOptionsChange = allowOptionsChange || pInfo.Name == ProgramConstants.PLAYERNAME; + bool allowPlayerOptionsChange = allowOptionsChange || pInfo == FindLocalPlayer(); ddPlayerSides[pId].SelectedIndex = pInfo.SideId; ddPlayerSides[pId].AllowDropDown = !playerExtraOptions.IsForceRandomSides && allowPlayerOptionsChange; @@ -2035,25 +2046,27 @@ protected virtual void CopyPlayerDataToUI() /// Override this in a derived class to kick players. /// /// The index of the player that should be kicked. - protected virtual void KickPlayer(int playerIndex) + protected virtual ValueTask KickPlayerAsync(int playerIndex) { // Do nothing by default + return ValueTask.CompletedTask; } /// /// Override this in a derived class to ban players. /// /// The index of the player that should be banned. - protected virtual void BanPlayer(int playerIndex) + protected virtual ValueTask BanPlayerAsync(int playerIndex) { // Do nothing by default + return ValueTask.CompletedTask; } /// /// Changes the current map and game mode. /// /// The new game mode map. - protected virtual void ChangeMap(GameModeMap gameModeMap) + protected virtual async ValueTask ChangeMapAsync(GameModeMap gameModeMap) { GameModeMap = gameModeMap; @@ -2185,7 +2198,7 @@ protected virtual void ChangeMap(GameModeMap gameModeMap) pInfo.TeamId = 1; } - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(true); MapPreviewBox.GameModeMap = GameModeMap; CopyPlayerDataToUI(); @@ -2257,7 +2270,7 @@ protected int GetRank() } } - PlayerInfo localPlayer = Players.Find(p => p.Name == ProgramConstants.PLAYERNAME); + PlayerInfo localPlayer = FindLocalPlayer(); if (localPlayer == null) return RANK_NONE; @@ -2429,7 +2442,7 @@ protected string AddGameOptionPreset(string name) return null; } - public bool LoadGameOptionPreset(string name) + public async ValueTask LoadGameOptionPresetAsync(string name) { GameOptionPreset preset = GameOptionPresets.Instance.GetPreset(name); if (preset == null) @@ -2454,7 +2467,7 @@ public bool LoadGameOptionPreset(string name) } disableGameOptionUpdateBroadcast = false; - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); return true; } diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/LANGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/LANGameLobby.cs index e7902ec2d..b6c4f3332 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/LANGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/LANGameLobby.cs @@ -11,77 +11,72 @@ using Rampastring.Tools; using Rampastring.XNAUI; using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; - +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Multiplayer.GameLobby { - public class LANGameLobby : MultiplayerGameLobby + internal sealed class LANGameLobby : MultiplayerGameLobby { private const int GAME_OPTION_SPECIAL_FLAG_COUNT = 5; private const double DROPOUT_TIMEOUT = 20.0; private const double GAME_BROADCAST_INTERVAL = 10.0; - private const string CHAT_COMMAND = "GLCHAT"; - private const string RETURN_COMMAND = "RETURN"; - private const string GET_READY_COMMAND = "GETREADY"; - private const string PLAYER_OPTIONS_REQUEST_COMMAND = "POREQ"; - private const string PLAYER_OPTIONS_BROADCAST_COMMAND = "POPTS"; - private const string PLAYER_JOIN_COMMAND = "JOIN"; - private const string PLAYER_QUIT_COMMAND = "QUIT"; - private const string GAME_OPTIONS_COMMAND = "OPTS"; - private const string PLAYER_READY_REQUEST = "READY"; - private const string LAUNCH_GAME_COMMAND = "LAUNCH"; - private const string FILE_HASH_COMMAND = "FHASH"; - private const string DICE_ROLL_COMMAND = "DR"; - public const string PING = "PING"; - - public LANGameLobby(WindowManager windowManager, string iniName, - TopBar topBar, LANColor[] chatColors, MapLoader mapLoader, DiscordHandler discordHandler) : - base(windowManager, iniName, topBar, mapLoader, discordHandler) + public LANGameLobby( + WindowManager windowManager, + string iniName, + TopBar topBar, + LANColor[] chatColors, + MapLoader mapLoader, + DiscordHandler discordHandler) + : base(windowManager, iniName, topBar, mapLoader, discordHandler) { this.chatColors = chatColors; encoding = Encoding.UTF8; hostCommandHandlers = new CommandHandlerBase[] { - new StringCommandHandler(CHAT_COMMAND, GameHost_HandleChatCommand), - new NoParamCommandHandler(RETURN_COMMAND, GameHost_HandleReturnCommand), - new StringCommandHandler(PLAYER_OPTIONS_REQUEST_COMMAND, HandlePlayerOptionsRequest), - new NoParamCommandHandler(PLAYER_QUIT_COMMAND, HandlePlayerQuit), - new StringCommandHandler(PLAYER_READY_REQUEST, GameHost_HandleReadyRequest), - new StringCommandHandler(FILE_HASH_COMMAND, HandleFileHashCommand), - new StringCommandHandler(DICE_ROLL_COMMAND, Host_HandleDiceRoll), - new NoParamCommandHandler(PING, s => { }), + new StringCommandHandler(LANCommands.CHAT_LOBBY_COMMAND, (sender, data) => GameHost_HandleChatCommandAsync(sender, data).HandleTask()), + new NoParamCommandHandler(LANCommands.RETURN, sender => GameHost_HandleReturnCommandAsync(sender).HandleTask()), + new StringCommandHandler(LANCommands.PLAYER_OPTIONS_REQUEST, (sender, data) => HandlePlayerOptionsRequestAsync(sender, data).HandleTask()), + new NoParamCommandHandler(LANCommands.PLAYER_QUIT_COMMAND, sender => HandlePlayerQuitAsync(sender).HandleTask()), + new StringCommandHandler(LANCommands.PLAYER_READY_REQUEST, (sender, autoReady) => GameHost_HandleReadyRequestAsync(sender, autoReady).HandleTask()), + new StringCommandHandler(LANCommands.FILE_HASH, HandleFileHashCommand), + new StringCommandHandler(LANCommands.DICE_ROLL, (sender, result) => Host_HandleDiceRollAsync(sender, result).HandleTask()), + new NoParamCommandHandler(LANCommands.PING, _ => { }) }; playerCommandHandlers = new LANClientCommandHandler[] { - new ClientStringCommandHandler(CHAT_COMMAND, Player_HandleChatCommand), - new ClientNoParamCommandHandler(GET_READY_COMMAND, HandleGetReadyCommand), - new ClientStringCommandHandler(RETURN_COMMAND, Player_HandleReturnCommand), - new ClientStringCommandHandler(PLAYER_OPTIONS_BROADCAST_COMMAND, HandlePlayerOptionsBroadcast), - new ClientStringCommandHandler(PlayerExtraOptions.LAN_MESSAGE_KEY, HandlePlayerExtraOptionsBroadcast), - new ClientStringCommandHandler(LAUNCH_GAME_COMMAND, HandleGameLaunchCommand), - new ClientStringCommandHandler(GAME_OPTIONS_COMMAND, HandleGameOptionsMessage), - new ClientStringCommandHandler(DICE_ROLL_COMMAND, Client_HandleDiceRoll), - new ClientNoParamCommandHandler(PING, HandlePing), + new ClientStringCommandHandler(LANCommands.CHAT_LOBBY_COMMAND, Player_HandleChatCommand), + new ClientNoParamCommandHandler(LANCommands.GET_READY, () => HandleGetReadyCommandAsync().HandleTask()), + new ClientStringCommandHandler(LANCommands.RETURN, Player_HandleReturnCommand), + new ClientStringCommandHandler(LANCommands.PLAYER_OPTIONS_BROADCAST, HandlePlayerOptionsBroadcast), + new ClientStringCommandHandler(LANCommands.PLAYER_EXTRA_OPTIONS, HandlePlayerExtraOptionsBroadcast), + new ClientStringCommandHandler(LANCommands.LAUNCH_GAME, gameId => HandleGameLaunchCommandAsync(gameId).HandleTask()), + new ClientStringCommandHandler(LANCommands.GAME_OPTIONS, data => HandleGameOptionsMessageAsync(data).HandleTask()), + new ClientStringCommandHandler(LANCommands.DICE_ROLL, Client_HandleDiceRoll), + new ClientNoParamCommandHandler(LANCommands.PING, () => HandlePingAsync().HandleTask()) }; localGame = ClientConfiguration.Instance.LocalGame; - WindowManager.GameClosing += WindowManager_GameClosing; + WindowManager.GameClosing += (_, _) => WindowManager_GameClosingAsync().HandleTask(); } - private void WindowManager_GameClosing(object sender, EventArgs e) + private async ValueTask WindowManager_GameClosingAsync() { - if (client != null && client.Connected) - Clear(); + if (client is { Connected: true }) + await ClearAsync(true).ConfigureAwait(false); + + cancellationTokenSource?.Cancel(); } private void HandleFileHashCommand(string sender, string fileHash) @@ -95,12 +90,11 @@ private void HandleFileHashCommand(string sender, string fileHash) CopyPlayerDataToUI(); } - public event EventHandler LobbyNotification; public event EventHandler GameLeft; public event EventHandler GameBroadcast; - private TcpListener listener; - private TcpClient client; + private Socket listener; + private Socket client; private IPEndPoint hostEndPoint; private LANColor[] chatColors; @@ -116,128 +110,162 @@ private void HandleFileHashCommand(string sender, string fileHash) private string overMessage = string.Empty; - private string localGame; + private readonly string localGame; private string localFileHash; + private EventHandler lpInfo_ConnectionLostFunc; + + private CancellationTokenSource cancellationTokenSource; + public override void Initialize() { IniNameOverride = nameof(LANGameLobby); + lpInfo_ConnectionLostFunc = (sender, _) => LpInfo_ConnectionLostAsync(sender).HandleTask(); base.Initialize(); PostInitialize(); } - public void SetUp(bool isHost, - IPEndPoint hostEndPoint, TcpClient client) + public async ValueTask SetUpAsync(bool isHost, IPEndPoint hostEndPoint, Socket client) { Refresh(isHost); this.hostEndPoint = hostEndPoint; + cancellationTokenSource?.Dispose(); + cancellationTokenSource = new CancellationTokenSource(); + if (isHost) { RandomSeed = new Random().Next(); - Thread thread = new Thread(ListenForClients); - thread.Start(); - - this.client = new TcpClient(); - this.client.Connect("127.0.0.1", ProgramConstants.LAN_GAME_LOBBY_PORT); - - byte[] buffer = encoding.GetBytes(PLAYER_JOIN_COMMAND + - ProgramConstants.LAN_DATA_SEPARATOR + ProgramConstants.PLAYERNAME); - - this.client.GetStream().Write(buffer, 0, buffer.Length); - this.client.GetStream().Flush(); + ListenForClientsAsync(cancellationTokenSource.Token).HandleTask(); + SendHostPlayerJoinedMessageAsync(cancellationTokenSource.Token).HandleTask(); var fhc = new FileHashCalculator(); fhc.CalculateHashes(GameModeMaps.GameModes); localFileHash = fhc.GetCompleteHash(); - RefreshMapSelectionUI(); + await RefreshMapSelectionUIAsync().ConfigureAwait(false); } else { this.client = client; } - new Thread(HandleServerCommunication).Start(); + HandleServerCommunicationAsync(cancellationTokenSource.Token).HandleTask(); if (IsHost) CopyPlayerDataToUI(); WindowManager.SelectedControl = tbChatInput; + btnLaunchGame.Enabled = true; } - public void PostJoin() + private async ValueTask SendHostPlayerJoinedMessageAsync(CancellationToken cancellationToken) + { + try + { + client = new Socket(SocketType.Stream, ProtocolType.Tcp); + + await client.ConnectAsync(IPAddress.Loopback, ProgramConstants.LAN_GAME_LOBBY_PORT, cancellationToken).ConfigureAwait(false); + + string message = LANCommands.PLAYER_JOIN + ProgramConstants.LAN_DATA_SEPARATOR + ProgramConstants.PLAYERNAME; + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + + buffer = buffer[..bytes]; + + await client.SendAsync(buffer, SocketFlags.None, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + public async ValueTask PostJoinAsync() { var fhc = new FileHashCalculator(); fhc.CalculateHashes(GameModeMaps.GameModes); - SendMessageToHost(FILE_HASH_COMMAND + " " + fhc.GetCompleteHash()); + await SendMessageToHostAsync(LANCommands.FILE_HASH + " " + fhc.GetCompleteHash(), cancellationTokenSource?.Token ?? default).ConfigureAwait(false); ResetAutoReadyCheckbox(); } #region Server code - private void ListenForClients() + private async ValueTask ListenForClientsAsync(CancellationToken cancellationToken) { - listener = new TcpListener(IPAddress.Any, ProgramConstants.LAN_GAME_LOBBY_PORT); - listener.Start(); + listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + + listener.Bind(new IPEndPoint(IPAddress.IPv6Any, ProgramConstants.LAN_GAME_LOBBY_PORT)); + listener.Listen(); - while (true) + while (!cancellationToken.IsCancellationRequested) { - TcpClient client; + Socket newClient; try { - client = listener.AcceptTcpClient(); + newClient = await listener.AcceptAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Listener error: " + ex.Message); + ProgramConstants.LogException(ex, "Listener error."); break; } - Logger.Log("New client connected from " + ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString()); + Logger.Log("New client connected from " + ((IPEndPoint)newClient.RemoteEndPoint).Address); if (Players.Count >= MAX_PLAYER_COUNT) { Logger.Log("Dropping client because of player limit."); - client.Close(); + newClient.Shutdown(SocketShutdown.Both); + newClient.Close(); continue; } if (Locked) { Logger.Log("Dropping client because the game room is locked."); - client.Close(); + newClient.Shutdown(SocketShutdown.Both); + newClient.Close(); continue; } LANPlayerInfo lpInfo = new LANPlayerInfo(encoding); - lpInfo.SetClient(client); + lpInfo.SetClient(newClient); - Thread thread = new Thread(new ParameterizedThreadStart(HandleClientConnection)); - thread.Start(lpInfo); + HandleClientConnectionAsync(lpInfo, cancellationToken).HandleTask(); } } - private void HandleClientConnection(object clientInfo) + private async ValueTask HandleClientConnectionAsync(LANPlayerInfo lpInfo, CancellationToken cancellationToken) { - var lpInfo = (LANPlayerInfo)clientInfo; - - byte[] message = new byte[1024]; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(1024); - while (true) + while (!cancellationToken.IsCancellationRequested) { - int bytesRead = 0; + int bytesRead; + Memory message; try { - bytesRead = lpInfo.TcpClient.GetStream().Read(message, 0, message.Length); + message = memoryOwner.Memory[..1024]; + bytesRead = await lpInfo.TcpClient.ReceiveAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Socket error with client " + lpInfo.IPAddress + "; removing. Message: " + ex.Message); + ProgramConstants.LogException(ex, "Socket error with client " + lpInfo.IPAddress + "; removing."); break; } @@ -248,8 +276,7 @@ private void HandleClientConnection(object clientInfo) break; } - string msg = encoding.GetString(message, 0, bytesRead); - + string msg = encoding.GetString(message.Span[..bytesRead]); string[] command = msg.Split(ProgramConstants.LAN_MESSAGE_SEPARATOR); string[] parts = command[0].Split(ProgramConstants.LAN_DATA_SEPARATOR); @@ -258,22 +285,22 @@ private void HandleClientConnection(object clientInfo) string name = parts[1].Trim(); - if (parts[0] == "JOIN" && !string.IsNullOrEmpty(name)) + if (parts[0] == LANCommands.PLAYER_JOIN && !string.IsNullOrEmpty(name)) { lpInfo.Name = name; - AddCallback(new Action(AddPlayer), lpInfo); + AddCallback(() => AddPlayerAsync(lpInfo, cancellationToken).HandleTask()); return; } break; } - if (lpInfo.TcpClient.Connected) - lpInfo.TcpClient.Close(); + lpInfo.TcpClient.Shutdown(SocketShutdown.Both); + lpInfo.TcpClient.Close(); } - private void AddPlayer(LANPlayerInfo lpInfo) + private async ValueTask AddPlayerAsync(LANPlayerInfo lpInfo, CancellationToken cancellationToken) { if (Players.Find(p => p.Name == lpInfo.Name) != null || Players.Count >= MAX_PLAYER_COUNT || Locked) @@ -285,19 +312,19 @@ private void AddPlayer(LANPlayerInfo lpInfo) Players[0].Ready = true; lpInfo.MessageReceived += LpInfo_MessageReceived; - lpInfo.ConnectionLost += LpInfo_ConnectionLost; + lpInfo.ConnectionLost += lpInfo_ConnectionLostFunc; AddNotice(string.Format("{0} connected from {1}".L10N("Client:Main:PlayerFromIP"), lpInfo.Name, lpInfo.IPAddress)); - lpInfo.StartReceiveLoop(); + lpInfo.StartReceiveLoopAsync(cancellationToken).HandleTask(); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - BroadcastPlayerExtraOptions(); - OnGameOptionChanged(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); + await OnGameOptionChangedAsync().ConfigureAwait(false); UpdateDiscordPresence(); } - private void LpInfo_ConnectionLost(object sender, EventArgs e) + private async ValueTask LpInfo_ConnectionLostAsync(object sender) { var lpInfo = (LANPlayerInfo)sender; CleanUpPlayer(lpInfo); @@ -306,7 +333,7 @@ private void LpInfo_ConnectionLost(object sender, EventArgs e) AddNotice(string.Format("{0} has left the game.".L10N("Client:Main:PlayerLeftGame"), lpInfo.Name)); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); if (lpInfo.Name == ProgramConstants.PLAYERNAME) ResetDiscordPresence(); @@ -316,8 +343,7 @@ private void LpInfo_ConnectionLost(object sender, EventArgs e) private void LpInfo_MessageReceived(object sender, NetworkMessageEventArgs e) { - AddCallback(new Action(HandleClientMessage), - e.Message, (LANPlayerInfo)sender); + AddCallback(() => HandleClientMessage(e.Message, (LANPlayerInfo)sender)); } private void HandleClientMessage(string data, LANPlayerInfo lpInfo) @@ -330,51 +356,53 @@ private void HandleClientMessage(string data, LANPlayerInfo lpInfo) return; } - Logger.Log("Unknown LAN command from " + lpInfo.ToString() + " : " + data); + Logger.Log("Unknown LAN command from " + lpInfo + " : " + data); } private void CleanUpPlayer(LANPlayerInfo lpInfo) { lpInfo.MessageReceived -= LpInfo_MessageReceived; - lpInfo.ConnectionLost -= LpInfo_ConnectionLost; + lpInfo.ConnectionLost -= lpInfo_ConnectionLostFunc; + lpInfo.TcpClient.Shutdown(SocketShutdown.Both); lpInfo.TcpClient.Close(); } #endregion - private void HandleServerCommunication() + private async ValueTask HandleServerCommunicationAsync(CancellationToken cancellationToken) { - byte[] message = new byte[1024]; - - var msg = string.Empty; - - int bytesRead = 0; - if (!client.Connected) return; - var stream = client.GetStream(); + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(1024); - while (true) + while (!cancellationToken.IsCancellationRequested) { - bytesRead = 0; + int bytesRead; + Memory message; try { - bytesRead = stream.Read(message, 0, message.Length); + message = memoryOwner.Memory[..1024]; + bytesRead = await client.ReceiveAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Reading data from the server failed! Message: " + ex.Message); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); + ProgramConstants.LogException(ex, "Reading data from the server failed!"); + await BtnLeaveGame_LeftClickAsync().ConfigureAwait(false); break; } if (bytesRead > 0) { - msg = encoding.GetString(message, 0, bytesRead); + string msg = encoding.GetString(message.Span[..bytesRead]); msg = overMessage + msg; + List commands = new List(); while (true) @@ -386,23 +414,21 @@ private void HandleServerCommunication() overMessage = msg; break; } - else - { - commands.Add(msg.Substring(0, index)); - msg = msg.Substring(index + 1); - } + + commands.Add(msg[..index]); + msg = msg[(index + 1)..]; } foreach (string cmd in commands) { - AddCallback(new Action(HandleMessageFromServer), cmd); + AddCallback(() => HandleMessageFromServer(cmd)); } continue; } Logger.Log("Reading data from the server failed (0 bytes received)!"); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); + await BtnLeaveGame_LeftClickAsync().ConfigureAwait(false); break; } } @@ -420,9 +446,9 @@ private void HandleMessageFromServer(string message) Logger.Log("Unknown LAN command from the server: " + message); } - protected override void BtnLeaveGame_LeftClick(object sender, EventArgs e) + protected override async ValueTask BtnLeaveGame_LeftClickAsync() { - Clear(); + await ClearAsync(false).ConfigureAwait(false); GameLeft?.Invoke(this, EventArgs.Empty); Disable(); } @@ -446,25 +472,32 @@ protected override void UpdateDiscordPresence(bool resetTimer = false) "LAN Game", IsHost, false, Locked, resetTimer); } - public override void Clear() + public override async ValueTask ClearAsync(bool exiting) { - base.Clear(); + await base.ClearAsync(exiting).ConfigureAwait(false); if (IsHost) { - BroadcastMessage(PLAYER_QUIT_COMMAND); + await BroadcastMessageAsync(LANCommands.PLAYER_QUIT_COMMAND).ConfigureAwait(false); Players.ForEach(p => CleanUpPlayer((LANPlayerInfo)p)); Players.Clear(); - listener.Stop(); + + if (listener.Connected) + listener.Shutdown(SocketShutdown.Both); + + listener.Close(); } else { - SendMessageToHost(PLAYER_QUIT_COMMAND); + await SendMessageToHostAsync(LANCommands.PLAYER_QUIT_COMMAND, cancellationTokenSource?.Token ?? default).ConfigureAwait(false); } - if (this.client.Connected) - this.client.Close(); + cancellationTokenSource.Cancel(); + + if (client.Connected) + client.Shutdown(SocketShutdown.Both); + client.Close(); ResetDiscordPresence(); } @@ -479,12 +512,12 @@ public void SetChatColorIndex(int colorIndex) protected override void AddNotice(string message, Color color) => lbChatMessages.AddMessage(null, message, color); - protected override void BroadcastPlayerOptions() + protected override async ValueTask BroadcastPlayerOptionsAsync() { if (!IsHost) return; - var sb = new ExtendedStringBuilder(PLAYER_OPTIONS_BROADCAST_COMMAND + " ", true); + var sb = new ExtendedStringBuilder(LANCommands.PLAYER_OPTIONS_BROADCAST + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; foreach (PlayerInfo pInfo in Players.Concat(AIPlayers)) { @@ -504,55 +537,57 @@ protected override void BroadcastPlayerOptions() sb.Append("-1"); } - BroadcastMessage(sb.ToString()); + await BroadcastMessageAsync(sb.ToString()).ConfigureAwait(false); } - protected override void BroadcastPlayerExtraOptions() + protected override async ValueTask BroadcastPlayerExtraOptionsAsync() { var playerExtraOptions = GetPlayerExtraOptions(); - BroadcastMessage(playerExtraOptions.ToLanMessage(), true); + await BroadcastMessageAsync(playerExtraOptions.ToLanMessage(), true).ConfigureAwait(false); } - protected override void HostLaunchGame() => BroadcastMessage(LAUNCH_GAME_COMMAND + " " + UniqueGameID); + protected override ValueTask HostLaunchGameAsync() => BroadcastMessageAsync(LANCommands.LAUNCH_GAME + " " + UniqueGameID); - protected override string GetIPAddressForPlayer(PlayerInfo player) + protected override IPAddress GetIPAddressForPlayer(PlayerInfo player) { var lpInfo = (LANPlayerInfo)player; - return lpInfo.IPAddress; + return lpInfo.IPAddress.MapToIPv4(); } - protected override void RequestPlayerOptions(int side, int color, int start, int team) + protected override ValueTask RequestPlayerOptionsAsync(int side, int color, int start, int team) { - var sb = new ExtendedStringBuilder(PLAYER_OPTIONS_REQUEST_COMMAND + " ", true); + var sb = new ExtendedStringBuilder(LANCommands.PLAYER_OPTIONS_REQUEST + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; sb.Append(side); sb.Append(color); sb.Append(start); sb.Append(team); - SendMessageToHost(sb.ToString()); + return SendMessageToHostAsync(sb.ToString(), cancellationTokenSource?.Token ?? default); } - protected override void RequestReadyStatus() => - SendMessageToHost(PLAYER_READY_REQUEST + " " + Convert.ToInt32(chkAutoReady.Checked)); + protected override ValueTask RequestReadyStatusAsync() + { + return SendMessageToHostAsync(LANCommands.PLAYER_READY_REQUEST + " " + Convert.ToInt32(chkAutoReady.Checked), cancellationTokenSource?.Token ?? default); + } - protected override void SendChatMessage(string message) + protected override ValueTask SendChatMessageAsync(string message) { - var sb = new ExtendedStringBuilder(CHAT_COMMAND + " ", true); + var sb = new ExtendedStringBuilder(LANCommands.CHAT_LOBBY_COMMAND + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; sb.Append(chatColorIndex); sb.Append(message); - SendMessageToHost(sb.ToString()); + return SendMessageToHostAsync(sb.ToString(), cancellationTokenSource?.Token ?? default); } - protected override void OnGameOptionChanged() + protected override async ValueTask OnGameOptionChangedAsync() { - base.OnGameOptionChanged(); + await base.OnGameOptionChangedAsync().ConfigureAwait(false); if (!IsHost) return; - var sb = new ExtendedStringBuilder(GAME_OPTIONS_COMMAND + " ", true); + var sb = new ExtendedStringBuilder(LANCommands.GAME_OPTIONS + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; foreach (GameLobbyCheckBox chkBox in CheckBoxes) { @@ -570,18 +605,18 @@ protected override void OnGameOptionChanged() sb.Append(FrameSendRate); sb.Append(Convert.ToInt32(RemoveStartingLocations)); - BroadcastMessage(sb.ToString()); + await BroadcastMessageAsync(sb.ToString()).ConfigureAwait(false); } - protected override void GetReadyNotification() + protected override async ValueTask GetReadyNotificationAsync() { - base.GetReadyNotification(); + await base.GetReadyNotificationAsync().ConfigureAwait(false); #if WINFORMS WindowManager.FlashWindow(); #endif if (IsHost) - BroadcastMessage(GET_READY_COMMAND); + await BroadcastMessageAsync(LANCommands.GET_READY).ConfigureAwait(false); } protected override void ClearPingIndicators() @@ -599,7 +634,7 @@ protected override void UpdatePlayerPingIndicator(PlayerInfo pInfo) /// /// The command to send. /// If true, only send this to other players. Otherwise, even the sender will receive their message. - private void BroadcastMessage(string message, bool otherPlayersOnly = false) + private async ValueTask BroadcastMessageAsync(string message, bool otherPlayersOnly = false) { if (!IsHost) return; @@ -607,37 +642,45 @@ private void BroadcastMessage(string message, bool otherPlayersOnly = false) foreach (PlayerInfo pInfo in Players.Where(p => !otherPlayersOnly || p.Name != ProgramConstants.PLAYERNAME)) { var lpInfo = (LANPlayerInfo)pInfo; - lpInfo.SendMessage(message); + await lpInfo.SendMessageAsync(message, cancellationTokenSource?.Token ?? default).ConfigureAwait(false); } } - protected override void PlayerExtraOptions_OptionsChanged(object sender, EventArgs e) + protected override async ValueTask PlayerExtraOptions_OptionsChangedAsync() { - base.PlayerExtraOptions_OptionsChanged(sender, e); - BroadcastPlayerExtraOptions(); + await base.PlayerExtraOptions_OptionsChangedAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); } - private void SendMessageToHost(string message) + private async ValueTask SendMessageToHostAsync(string message, CancellationToken cancellationToken) { if (!client.Connected) return; - byte[] buffer = encoding.GetBytes(message + ProgramConstants.LAN_MESSAGE_SEPARATOR); - - NetworkStream ns = client.GetStream(); + message += ProgramConstants.LAN_MESSAGE_SEPARATOR; try { - ns.Write(buffer, 0, buffer.Length); - ns.Flush(); + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + + buffer = buffer[..bytes]; + + await client.SendAsync(buffer, SocketFlags.None, cancellationToken).ConfigureAwait(false); } - catch + catch (OperationCanceledException) { - Logger.Log("Sending message to game host failed!"); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, "Sending message to game host failed!"); } } - protected override void UnlockGame(bool manual) + protected override ValueTask UnlockGameAsync(bool manual) { Locked = false; @@ -645,9 +688,11 @@ protected override void UnlockGame(bool manual) if (manual) AddNotice("You've unlocked the game room.".L10N("Client:Main:RoomUnockedByYou")); + + return ValueTask.CompletedTask; } - protected override void LockGame() + protected override ValueTask LockGameAsync() { Locked = true; @@ -655,27 +700,26 @@ protected override void LockGame() if (Locked) AddNotice("You've locked the game room.".L10N("Client:Main:RoomLockedByYou")); + + return ValueTask.CompletedTask; } - protected override void GameProcessExited() + protected override async ValueTask GameProcessExitedAsync() { - base.GameProcessExited(); - - SendMessageToHost(RETURN_COMMAND); + await base.GameProcessExitedAsync().ConfigureAwait(false); + await SendMessageToHostAsync(LANCommands.RETURN, cancellationTokenSource?.Token ?? default).ConfigureAwait(false); if (IsHost) { RandomSeed = new Random().Next(); - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); ClearReadyStatuses(); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - BroadcastPlayerExtraOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); + await BroadcastPlayerExtraOptionsAsync().ConfigureAwait(false); if (Players.Count < MAX_PLAYER_COUNT) - { - UnlockGame(true); - } + await UnlockGameAsync(true).ConfigureAwait(false); } } @@ -699,14 +743,14 @@ public override void Update(GameTime gameTime) for (int i = 1; i < Players.Count; i++) { LANPlayerInfo lpInfo = (LANPlayerInfo)Players[i]; - if (!lpInfo.Update(gameTime)) + if (!Task.Run(() => lpInfo.UpdateAsync(gameTime).HandleTask(true)).Result) { CleanUpPlayer(lpInfo); Players.RemoveAt(i); AddNotice(string.Format("{0} - connection timed out".L10N("Client:Main:PlayerTimeout"), lpInfo.Name)); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); - BroadcastPlayerExtraOptions(); + Task.Run(() => BroadcastPlayerOptionsAsync().HandleTask()).Wait(); + Task.Run(() => BroadcastPlayerExtraOptionsAsync().HandleTask()).Wait(); UpdateDiscordPresence(); i--; } @@ -725,11 +769,7 @@ public override void Update(GameTime gameTime) timeSinceLastReceivedCommand += gameTime.ElapsedGameTime; if (timeSinceLastReceivedCommand > TimeSpan.FromSeconds(DROPOUT_TIMEOUT)) - { - LobbyNotification?.Invoke(this, - new LobbyNotificationEventArgs("Connection to the game host timed out.".L10N("Client:Main:HostConnectTimeOut"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } + Task.Run(() => BtnLeaveGame_LeftClickAsync().HandleTask()).Wait(); } base.Update(gameTime); @@ -737,7 +777,10 @@ public override void Update(GameTime gameTime) private void BroadcastGame() { - var sb = new ExtendedStringBuilder("GAME ", true); + if (GameMode == null || Map == null) + return; + + var sb = new ExtendedStringBuilder(LANCommands.GAME + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; sb.Append(ProgramConstants.LAN_PROTOCOL_REVISION); sb.Append(ProgramConstants.GAME_VERSION); @@ -757,7 +800,7 @@ private void BroadcastGame() #region Command Handlers - private void GameHost_HandleChatCommand(string sender, string data) + private async ValueTask GameHost_HandleChatCommandAsync(string sender, string data) { string[] parts = data.Split(ProgramConstants.LAN_DATA_SEPARATOR); @@ -769,7 +812,7 @@ private void GameHost_HandleChatCommand(string sender, string data) if (colorIndex < 0 || colorIndex >= chatColors.Length) return; - BroadcastMessage(CHAT_COMMAND + " " + sender + ProgramConstants.LAN_DATA_SEPARATOR + data); + await BroadcastMessageAsync(LANCommands.CHAT_LOBBY_COMMAND + " " + sender + ProgramConstants.LAN_DATA_SEPARATOR + data).ConfigureAwait(false); } private void Player_HandleChatCommand(string data) @@ -790,23 +833,21 @@ private void Player_HandleChatCommand(string data) chatColors[colorIndex].XNAColor, DateTime.Now, parts[2])); } - private void GameHost_HandleReturnCommand(string sender) - { - BroadcastMessage(RETURN_COMMAND + ProgramConstants.LAN_DATA_SEPARATOR + sender); - } + private ValueTask GameHost_HandleReturnCommandAsync(string sender) + => BroadcastMessageAsync(LANCommands.RETURN + ProgramConstants.LAN_DATA_SEPARATOR + sender); private void Player_HandleReturnCommand(string sender) { ReturnNotification(sender); } - private void HandleGetReadyCommand() + private async ValueTask HandleGetReadyCommandAsync() { if (!IsHost) - GetReadyNotification(); + await GetReadyNotificationAsync().ConfigureAwait(false); } - private void HandlePlayerOptionsRequest(string sender, string data) + private async ValueTask HandlePlayerOptionsRequestAsync(string sender, string data) { if (!IsHost) return; @@ -860,7 +901,7 @@ private void HandlePlayerOptionsRequest(string sender, string data) pInfo.TeamId = team; CopyPlayerDataToUI(); - BroadcastPlayerOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); } private void HandlePlayerExtraOptionsBroadcast(string data) => ApplyPlayerExtraOptions(null, data); @@ -893,7 +934,7 @@ private void HandlePlayerOptionsBroadcast(string data) int start = Conversions.IntFromString(parts[baseIndex + 3], -1); int team = Conversions.IntFromString(parts[baseIndex + 4], -1); int readyStatus = Conversions.IntFromString(parts[baseIndex + 5], -1); - string ipAddress = parts[baseIndex + 6]; + var ipAddress = IPAddress.Parse(parts[baseIndex + 6]); int aiLevel = Conversions.IntFromString(parts[baseIndex + 7], -1); if (side < 0 || side > SideCount + RandomSelectorCount) @@ -908,8 +949,8 @@ private void HandlePlayerOptionsBroadcast(string data) if (team < 0 || team > 4) return; - if (ipAddress == "127.0.0.1") - ipAddress = hostEndPoint.Address.ToString(); + if (IPAddress.IsLoopback(ipAddress)) + ipAddress = hostEndPoint.Address.MapToIPv4(); bool isAi = aiLevel > -1; if (aiLevel > 2) @@ -947,7 +988,7 @@ private void HandlePlayerOptionsBroadcast(string data) UpdateDiscordPresence(); } - private void HandlePlayerQuit(string sender) + private async ValueTask HandlePlayerQuitAsync(string sender) { PlayerInfo pInfo = Players.Find(p => p.Name == sender); @@ -958,11 +999,11 @@ private void HandlePlayerQuit(string sender) Players.Remove(pInfo); ClearReadyStatuses(); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); UpdateDiscordPresence(); } - private void HandleGameOptionsMessage(string data) + private async ValueTask HandleGameOptionsMessageAsync(string data) { if (IsHost) return; @@ -977,14 +1018,14 @@ private void HandleGameOptionsMessage(string data) return; } - int randomSeed = Conversions.IntFromString(parts[parts.Length - GAME_OPTION_SPECIAL_FLAG_COUNT], -1); + int randomSeed = Conversions.IntFromString(parts[^GAME_OPTION_SPECIAL_FLAG_COUNT], -1); if (randomSeed == -1) return; RandomSeed = randomSeed; - string mapSHA1 = parts[parts.Length - (GAME_OPTION_SPECIAL_FLAG_COUNT - 1)]; - string gameMode = parts[parts.Length - (GAME_OPTION_SPECIAL_FLAG_COUNT - 2)]; + string mapSHA1 = parts[^(GAME_OPTION_SPECIAL_FLAG_COUNT - 1)]; + string gameMode = parts[^(GAME_OPTION_SPECIAL_FLAG_COUNT - 2)]; GameModeMap gameModeMap = GameModeMaps.Find(gmm => gmm.GameMode.Name == gameMode && gmm.Map.SHA1 == mapSHA1); @@ -992,14 +1033,14 @@ private void HandleGameOptionsMessage(string data) { AddNotice("The game host has selected a map that doesn't exist on your installation.".L10N("Client:Main:MapNotExist") + "The host needs to change the map or you won't be able to play.".L10N("Client:Main:HostNeedChangeMapForYou")); - ChangeMap(null); + await ChangeMapAsync(null).ConfigureAwait(false); return; } if (GameModeMap != gameModeMap) - ChangeMap(gameModeMap); + await ChangeMapAsync(gameModeMap).ConfigureAwait(false); - int frameSendRate = Conversions.IntFromString(parts[parts.Length - (GAME_OPTION_SPECIAL_FLAG_COUNT - 3)], FrameSendRate); + int frameSendRate = Conversions.IntFromString(parts[^(GAME_OPTION_SPECIAL_FLAG_COUNT - 3)], FrameSendRate); if (frameSendRate != FrameSendRate) { FrameSendRate = frameSendRate; @@ -1007,7 +1048,7 @@ private void HandleGameOptionsMessage(string data) } bool removeStartingLocations = Convert.ToBoolean(Conversions.IntFromString( - parts[parts.Length - (GAME_OPTION_SPECIAL_FLAG_COUNT - 4)], Convert.ToInt32(RemoveStartingLocations))); + parts[^(GAME_OPTION_SPECIAL_FLAG_COUNT - 4)], Convert.ToInt32(RemoveStartingLocations))); SetRandomStartingLocations(removeStartingLocations); for (int i = 0; i < CheckBoxes.Count; i++) @@ -1049,7 +1090,7 @@ private void HandleGameOptionsMessage(string data) } } - private void GameHost_HandleReadyRequest(string sender, string autoReady) + private async ValueTask GameHost_HandleReadyRequestAsync(string sender, string autoReady) { PlayerInfo pInfo = Players.Find(p => p.Name == sender); @@ -1059,35 +1100,32 @@ private void GameHost_HandleReadyRequest(string sender, string autoReady) pInfo.Ready = true; pInfo.AutoReady = Convert.ToBoolean(Conversions.IntFromString(autoReady, 0)); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); } - private void HandleGameLaunchCommand(string gameId) + private async ValueTask HandleGameLaunchCommandAsync(string gameId) { Players.ForEach(pInfo => pInfo.IsInGame = true); UniqueGameID = Conversions.IntFromString(gameId, -1); + if (UniqueGameID < 0) return; CopyPlayerDataToUI(); - StartGame(); + await StartGameAsync().ConfigureAwait(false); } - private void HandlePing() - { - SendMessageToHost(PING); - } + private ValueTask HandlePingAsync() + => SendMessageToHostAsync(LANCommands.PING, cancellationTokenSource?.Token ?? default); - protected override void BroadcastDiceRoll(int dieSides, int[] results) + protected override async ValueTask BroadcastDiceRollAsync(int dieSides, int[] results) { string resultString = string.Join(",", results); - SendMessageToHost($"DR {dieSides},{resultString}"); + await SendMessageToHostAsync($"{LANCommands.DICE_ROLL} {dieSides},{resultString}", cancellationTokenSource?.Token ?? default).ConfigureAwait(false); } - private void Host_HandleDiceRoll(string sender, string result) - { - BroadcastMessage($"{DICE_ROLL_COMMAND} {sender}{ProgramConstants.LAN_DATA_SEPARATOR}{result}"); - } + private ValueTask Host_HandleDiceRollAsync(string sender, string result) + => BroadcastMessageAsync($"{LANCommands.DICE_ROLL} {sender}{ProgramConstants.LAN_DATA_SEPARATOR}{result}"); private void Client_HandleDiceRoll(string data) { @@ -1110,16 +1148,6 @@ protected override void WriteSpawnIniAdditions(IniFile iniFile) } } - public class LobbyNotificationEventArgs : EventArgs - { - public LobbyNotificationEventArgs(string notification) - { - Notification = notification; - } - - public string Notification { get; private set; } - } - public class GameBroadcastEventArgs : EventArgs { public GameBroadcastEventArgs(string message) @@ -1127,7 +1155,6 @@ public GameBroadcastEventArgs(string message) Message = message; } - public string Message { get; private set; } + public string Message { get; } } - -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/MapPreviewBox.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/MapPreviewBox.cs index 31744b247..39ffa8e38 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/MapPreviewBox.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/MapPreviewBox.cs @@ -10,6 +10,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; +using ClientCore.Extensions; using ClientGUI; using ClientCore.Extensions; @@ -32,13 +34,10 @@ public ExtraMapPreviewTexture(Texture2D texture, Point point, bool toggleable) /// /// The picture box for displaying the map preview. /// - public class MapPreviewBox : XNAPanel + internal sealed class MapPreviewBox : XNAPanel { private const int MAX_STARTING_LOCATIONS = 8; - public delegate void LocalStartingLocationSelectedEventHandler(object sender, - LocalStartingLocationEventArgs e); - public event EventHandler LocalStartingLocationSelected; public event EventHandler StartingLocationApplied; @@ -49,7 +48,6 @@ public MapPreviewBox(WindowManager windowManager) : base(windowManager) FontIndex = 1; } - public void SetFields(List players, List aiPlayers, List mpColors, string[] sides, IniFile gameOptionsIni) { this.players = players; @@ -95,10 +93,9 @@ public void SetFields(List players, List aiPlayers, List AddChild(briefingBox); briefingBox.Disable(); - ClientRectangleUpdated += (s, e) => UpdateMap(); + ClientRectangleUpdated += (_, _) => UpdateMap(); } - private GameModeMap _gameModeMap; public GameModeMap GameModeMap { @@ -223,7 +220,7 @@ public override void Initialize() base.Initialize(); - ClientRectangleUpdated += (s, e) => UpdateMap(); + ClientRectangleUpdated += (_, _) => UpdateMap(); RightClick += MapPreviewBox_RightClick; diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs index 4b9a44063..0257c52af 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using Rampastring.XNAUI; -using Rampastring.XNAUI.XNAControls; using Microsoft.Xna.Framework; using ClientCore; using System.IO; @@ -12,7 +11,10 @@ using DTAClient.Domain.Multiplayer; using ClientGUI; using System.Text; +using System.Threading.Tasks; +using ClientCore.Extensions; using DTAClient.Domain; +using DTAClient.Domain.Multiplayer.CnCNet; using Microsoft.Xna.Framework.Graphics; using ClientCore.Extensions; @@ -21,36 +23,42 @@ namespace DTAClient.DXGUI.Multiplayer.GameLobby /// /// A generic base class for multiplayer game lobbies (CnCNet and LAN). /// - public abstract class MultiplayerGameLobby : GameLobbyBase, ISwitchable + internal abstract class MultiplayerGameLobby : GameLobbyBase, ISwitchable { private const int MAX_DICE = 10; private const int MAX_DIE_SIDES = 100; - public MultiplayerGameLobby(WindowManager windowManager, string iniName, - TopBar topBar, MapLoader mapLoader, DiscordHandler discordHandler) + public MultiplayerGameLobby( + WindowManager windowManager, + string iniName, + TopBar topBar, + MapLoader mapLoader, + DiscordHandler discordHandler) : base(windowManager, iniName, mapLoader, true, discordHandler) { TopBar = topBar; chatBoxCommands = new List { - new ChatBoxCommand("HIDEMAPS", "Hide map list (game host only)".L10N("Client:Main:ChatboxCommandHideMapsHelp"), true, + new(CnCNetLobbyCommands.HIDEMAPS, "Hide map list (game host only)".L10N("Client:Main:ChatboxCommandHideMapsHelp"), true, s => HideMapList()), - new ChatBoxCommand("SHOWMAPS", "Show map list (game host only)".L10N("Client:Main:ChatboxCommandShowMapsHelp"), true, + new(CnCNetLobbyCommands.SHOWMAPS, "Show map list (game host only)".L10N("Client:Main:ChatboxCommandShowMapsHelp"), true, s => ShowMapList()), - new ChatBoxCommand("FRAMESENDRATE", "Change order lag / FrameSendRate (default 7) (game host only)".L10N("Client:Main:ChatboxCommandFrameSendRateHelp"), true, - s => SetFrameSendRate(s)), - new ChatBoxCommand("MAXAHEAD", "Change MaxAhead (default 0) (game host only)".L10N("Client:Main:ChatboxCommandMaxAheadHelp"), true, - s => SetMaxAhead(s)), - new ChatBoxCommand("PROTOCOLVERSION", "Change ProtocolVersion (default 2) (game host only)".L10N("Client:Main:ChatboxCommandProtocolVersionHelp"), true, - s => SetProtocolVersion(s)), - new ChatBoxCommand("LOADMAP", "Load a custom map with given filename from /Maps/Custom/ folder.".L10N("Client:Main:ChatboxCommandLoadMapHelp"), true, LoadCustomMap), - new ChatBoxCommand("RANDOMSTARTS", "Enables completely random starting locations (Tiberian Sun based games only).".L10N("Client:Main:ChatboxCommandRandomStartsHelp"), true, - s => SetStartingLocationClearance(s)), - new ChatBoxCommand("ROLL", "Roll dice, for example /roll 3d6".L10N("Client:Main:ChatboxCommandRollHelp"), false, RollDiceCommand), - new ChatBoxCommand("SAVEOPTIONS", "Save game option preset so it can be loaded later".L10N("Client:Main:ChatboxCommandSaveOptionsHelp"), false, HandleGameOptionPresetSaveCommand), - new ChatBoxCommand("LOADOPTIONS", "Load game option preset".L10N("Client:Main:ChatboxCommandLoadOptionsHelp"), true, HandleGameOptionPresetLoadCommand) + new(CnCNetLobbyCommands.FRAMESENDRATE, "Change order lag / FrameSendRate (default 7) (game host only)".L10N("Client:Main:ChatboxCommandFrameSendRateHelp"), true, + s => SetFrameSendRateAsync(s).HandleTask()), + new(CnCNetLobbyCommands.MAXAHEAD, "Change MaxAhead (default 0) (game host only)".L10N("Client:Main:ChatboxCommandMaxAheadHelp"), true, + s => SetMaxAheadAsync(s).HandleTask()), + new(CnCNetLobbyCommands.PROTOCOLVERSION, "Change ProtocolVersion (default 2) (game host only)".L10N("Client:Main:ChatboxCommandProtocolVersionHelp"), true, + s => SetProtocolVersionAsync(s).HandleTask()), + new(CnCNetLobbyCommands.LOADMAP, "Load a custom map with given filename from /Maps/Custom/ folder.".L10N("Client:Main:ChatboxCommandLoadMapHelp"), true, LoadCustomMap), + new(CnCNetLobbyCommands.RANDOMSTARTS, "Enables completely random starting locations (Tiberian Sun based games only).".L10N("Client:Main:ChatboxCommandRandomStartsHelp"), true, + s => SetStartingLocationClearanceAsync(s).HandleTask()), + new(CnCNetLobbyCommands.ROLL, "Roll dice, for example /roll 3d6".L10N("Client:Main:ChatboxCommandRollHelp"), false, dieType => RollDiceCommandAsync(dieType).HandleTask()), + new(CnCNetLobbyCommands.SAVEOPTIONS, "Save game option preset so it can be loaded later".L10N("Client:Main:ChatboxCommandSaveOptionsHelp"), false, HandleGameOptionPresetSaveCommand), + new(CnCNetLobbyCommands.LOADOPTIONS, "Load game option preset".L10N("Client:Main:ChatboxCommandLoadOptionsHelp"), true, presetName => HandleGameOptionPresetLoadCommandAsync(presetName).HandleTask()) }; + + chkAutoReady_CheckedChangedFunc = (_, _) => ChkAutoReady_CheckedChangedAsync().HandleTask(); } protected XNAPlayerSlotIndicator[] StatusIndicators; @@ -105,9 +113,11 @@ protected bool Locked private FileSystemWatcher fsw; - private bool gameSaved = false; + private bool gameSaved; + + private bool lastMapChangeWasInvalid; - private bool lastMapChangeWasInvalid = false; + private EventHandler chkAutoReady_CheckedChangedFunc; /// /// Allows derived classes to add their own chat box commands. @@ -122,8 +132,7 @@ public override void Initialize() base.Initialize(); // DisableSpectatorReadyChecking = GameOptionsIni.GetBooleanValue("General", "DisableSpectatorReadyChecking", false); - - PingTextures = new Texture2D[5] + PingTextures = new[] { AssetLoader.LoadTexture("ping0.png"), AssetLoader.LoadTexture("ping1.png"), @@ -143,8 +152,7 @@ public override void Initialize() { var indicatorPlayerReady = new XNAPlayerSlotIndicator(WindowManager); indicatorPlayerReady.Name = "playerStatusIndicator" + i; - indicatorPlayerReady.ClientRectangle = new Rectangle(statusIndicatorX, ddPlayerTeams[i].Y + statusIndicatorY, - 0, 0); + indicatorPlayerReady.ClientRectangle = new Rectangle(statusIndicatorX, ddPlayerTeams[i].Y + statusIndicatorY, 0, 0); PlayerOptionsPanel.AddChild(indicatorPlayerReady); @@ -158,17 +166,17 @@ public override void Initialize() tbChatInput = FindChild(nameof(tbChatInput)); tbChatInput.MaximumTextLength = 150; - tbChatInput.EnterPressed += TbChatInput_EnterPressed; + tbChatInput.EnterPressed += (_, _) => TbChatInput_EnterPressedAsync().HandleTask(); btnLockGame = FindChild(nameof(btnLockGame)); - btnLockGame.LeftClick += BtnLockGame_LeftClick; + btnLockGame.LeftClick += (_, _) => HandleLockGameButtonClickAsync().HandleTask(); chkAutoReady = FindChild(nameof(chkAutoReady)); - chkAutoReady.CheckedChanged += ChkAutoReady_CheckedChanged; + chkAutoReady.CheckedChanged += chkAutoReady_CheckedChangedFunc; chkAutoReady.Disable(); MapPreviewBox.LocalStartingLocationSelected += MapPreviewBox_LocalStartingLocationSelected; - MapPreviewBox.StartingLocationApplied += MapPreviewBox_StartingLocationApplied; + MapPreviewBox.StartingLocationApplied += (_, _) => MapPreviewBox_StartingLocationAppliedAsync().HandleTask(); sndJoinSound = new EnhancedSoundEffect("joingame.wav", 0.0, 0.0, ClientConfiguration.Instance.SoundGameLobbyJoinCooldown); sndLeaveSound = new EnhancedSoundEffect("leavegame.wav", 0.0, 0.0, ClientConfiguration.Instance.SoundGameLobbyLeaveCooldown); @@ -178,7 +186,7 @@ public override void Initialize() if (SavedGameManager.AreSavedGamesAvailable()) { - fsw = new FileSystemWatcher(SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "Saved Games"), "*.NET"); + fsw = new FileSystemWatcher(SafePath.CombineDirectoryPath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY), "*.NET"); fsw.Created += fsw_Created; fsw.Changed += fsw_Created; fsw.EnableRaisingEvents = false; @@ -229,11 +237,9 @@ protected void PostInitialize() } private void fsw_Created(object sender, FileSystemEventArgs e) - { - AddCallback(new Action(FSWEvent), e); - } + => AddCallback(() => FSWEventAsync(e).HandleTask()); - private void FSWEvent(FileSystemEventArgs e) + private async ValueTask FSWEventAsync(FileSystemEventArgs e) { Logger.Log("FSW Event: " + e.FullPath); @@ -249,11 +255,11 @@ private void FSWEvent(FileSystemEventArgs e) gameSaved = true; - SavedGameManager.RenameSavedGame(); + await SavedGameManager.RenameSavedGameAsync().ConfigureAwait(false); } } - protected override void StartGame() + protected override ValueTask StartGameAsync() { if (fsw != null) fsw.EnableRaisingEvents = true; @@ -261,10 +267,10 @@ protected override void StartGame() for (int pId = 0; pId < Players.Count; pId++) Players[pId].IsInGame = true; - base.StartGame(); + return base.StartGameAsync(); } - protected override void GameProcessExited() + protected override async ValueTask GameProcessExitedAsync() { gameSaved = false; @@ -272,18 +278,19 @@ protected override void GameProcessExited() fsw.EnableRaisingEvents = false; PlayerInfo pInfo = Players.Find(p => p.Name == ProgramConstants.PLAYERNAME); + pInfo.IsInGame = false; - base.GameProcessExited(); + await base.GameProcessExitedAsync().ConfigureAwait(true); if (IsHost) { GenerateGameID(); - DdGameModeMapFilter_SelectedIndexChanged(null, EventArgs.Empty); // Refresh ranks + await DdGameModeMapFilter_SelectedIndexChangedAsync().ConfigureAwait(false); // Refresh ranks } else if (chkAutoReady.Checked) { - RequestReadyStatus(); + await RequestReadyStatusAsync().ConfigureAwait(false); } } @@ -307,24 +314,19 @@ private void GenerateGameID() } } - private void BtnLockGame_LeftClick(object sender, EventArgs e) - { - HandleLockGameButtonClick(); - } - - protected virtual void HandleLockGameButtonClick() + protected virtual async ValueTask HandleLockGameButtonClickAsync() { if (Locked) - UnlockGame(true); + await UnlockGameAsync(true).ConfigureAwait(false); else - LockGame(); + await LockGameAsync().ConfigureAwait(false); } - protected abstract void LockGame(); + protected abstract ValueTask LockGameAsync(); - protected abstract void UnlockGame(bool manual); + protected abstract ValueTask UnlockGameAsync(bool announce); - private void TbChatInput_EnterPressed(object sender, EventArgs e) + private async ValueTask TbChatInput_EnterPressedAsync() { if (string.IsNullOrEmpty(tbChatInput.Text)) return; @@ -339,13 +341,13 @@ private void TbChatInput_EnterPressed(object sender, EventArgs e) if (spaceIndex == -1) { - command = text.Substring(1).ToUpper(); + command = text[1..].ToUpper(); parameters = string.Empty; } else { command = text.Substring(1, spaceIndex - 1); - parameters = text.Substring(spaceIndex + 1); + parameters = text[(spaceIndex + 1)..]; } tbChatInput.Text = string.Empty; @@ -376,25 +378,25 @@ private void TbChatInput_EnterPressed(object sender, EventArgs e) return; } - SendChatMessage(tbChatInput.Text); + await SendChatMessageAsync(tbChatInput.Text).ConfigureAwait(false); tbChatInput.Text = string.Empty; } - private void ChkAutoReady_CheckedChanged(object sender, EventArgs e) + private async ValueTask ChkAutoReady_CheckedChangedAsync() { UpdateLaunchGameButtonStatus(); - RequestReadyStatus(); + await RequestReadyStatusAsync().ConfigureAwait(false); } protected void ResetAutoReadyCheckbox() { - chkAutoReady.CheckedChanged -= ChkAutoReady_CheckedChanged; + chkAutoReady.CheckedChanged -= chkAutoReady_CheckedChangedFunc; chkAutoReady.Checked = false; - chkAutoReady.CheckedChanged += ChkAutoReady_CheckedChanged; + chkAutoReady.CheckedChanged += chkAutoReady_CheckedChangedFunc; UpdateLaunchGameButtonStatus(); } - private void SetFrameSendRate(string value) + private async ValueTask SetFrameSendRateAsync(string value) { bool success = int.TryParse(value, out int intValue); @@ -407,11 +409,12 @@ private void SetFrameSendRate(string value) FrameSendRate = intValue; AddNotice(string.Format("FrameSendRate has been changed to {0}".L10N("Client:Main:FrameSendRateChanged"), intValue)); - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); + ClearReadyStatuses(); ClearReadyStatuses(); } - private void SetMaxAhead(string value) + private async ValueTask SetMaxAheadAsync(string value) { bool success = int.TryParse(value, out int intValue); @@ -424,11 +427,11 @@ private void SetMaxAhead(string value) MaxAhead = intValue; AddNotice(string.Format("MaxAhead has been changed to {0}".L10N("Client:Main:MaxAheadChanged"), intValue)); - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); ClearReadyStatuses(); } - private void SetProtocolVersion(string value) + private async ValueTask SetProtocolVersionAsync(string value) { bool success = int.TryParse(value, out int intValue); @@ -447,17 +450,17 @@ private void SetProtocolVersion(string value) ProtocolVersion = intValue; AddNotice(string.Format("ProtocolVersion has been changed to {0}".L10N("Client:Main:ProtocolVersionChanged"), intValue)); - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); ClearReadyStatuses(); } - private void SetStartingLocationClearance(string value) + private async ValueTask SetStartingLocationClearanceAsync(string value) { bool removeStartingLocations = Conversions.BooleanFromString(value, RemoveStartingLocations); SetRandomStartingLocations(removeStartingLocations); - OnGameOptionChanged(); + await OnGameOptionChangedAsync().ConfigureAwait(false); ClearReadyStatuses(); } @@ -482,7 +485,7 @@ protected void SetRandomStartingLocations(bool newValue) /// Handles the dice rolling command. /// /// The parameters given for the command by the user. - private void RollDiceCommand(string dieType) + private async ValueTask RollDiceCommandAsync(string dieType) { int dieSides = 6; int dieCount = 1; @@ -519,7 +522,7 @@ private void RollDiceCommand(string dieType) results[i] = random.Next(1, dieSides + 1); } - BroadcastDiceRoll(dieSides, results); + await BroadcastDiceRollAsync(dieSides, results).ConfigureAwait(false); } /// @@ -545,7 +548,7 @@ private void LoadCustomMap(string mapName) /// /// The number of sides in the dice. /// The results of the dice roll. - protected abstract void BroadcastDiceRoll(int dieSides, int[] results); + protected abstract ValueTask BroadcastDiceRollAsync(int dieSides, int[] results); /// /// Parses and lists the results of rolling dice. @@ -595,7 +598,7 @@ protected void PrintDiceRollResult(string senderName, int dieSides, int[] result )); } - protected abstract void SendChatMessage(string message); + protected abstract ValueTask SendChatMessageAsync(string message); /// /// Changes the game lobby's UI depending on whether the local player is the host. @@ -609,7 +612,6 @@ protected void Refresh(bool isHost) UpdateMapPreviewBoxEnabledStatus(); PlayerExtraOptionsPanel?.SetIsHost(isHost); - //MapPreviewBox.EnableContextMenu = IsHost; btnLaunchGame.Text = IsHost ? BTN_LAUNCH_GAME : BTN_LAUNCH_READY; @@ -734,11 +736,11 @@ private void MapPreviewBox_LocalStartingLocationSelected(object sender, LocalSta ddPlayerStarts[mTopIndex].SelectedIndex = e.StartingLocationIndex; } - private void MapPreviewBox_StartingLocationApplied(object sender, EventArgs e) + private async ValueTask MapPreviewBox_StartingLocationAppliedAsync() { ClearReadyStatuses(); CopyPlayerDataToUI(); - BroadcastPlayerOptions(); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); } /// @@ -747,17 +749,17 @@ private void MapPreviewBox_StartingLocationApplied(object sender, EventArgs e) /// launches the game if it's allowed. If the local player isn't the game host, /// sends a ready request. /// - protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) + protected override async ValueTask BtnLaunchGame_LeftClickAsync() { if (!IsHost) { - RequestReadyStatus(); + await RequestReadyStatusAsync().ConfigureAwait(false); return; } if (!Locked) { - LockGameNotification(); + await LockGameNotificationAsync().ConfigureAwait(false); return; } @@ -773,16 +775,16 @@ protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) { if (occupiedColorIds.Contains(player.ColorId) && player.ColorId > 0) { - SharedColorsNotification(); + await SharedColorsNotificationAsync().ConfigureAwait(false); return; } occupiedColorIds.Add(player.ColorId); } - if (AIPlayers.Count(pInfo => pInfo.SideId == ddPlayerSides[0].Items.Count - 1) > 0) + if (AIPlayers.Any(pInfo => pInfo.SideId == ddPlayerSides[0].Items.Count - 1)) { - AISpectatorsNotification(); + await AISpectatorsNotificationAsync().ConfigureAwait(false); return; } @@ -797,7 +799,7 @@ protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) p => p.StartingLocation == pInfo.StartingLocation && p.Name != pInfo.Name) != null) { - SharedStartingLocationNotification(); + await SharedStartingLocationNotificationAsync().ConfigureAwait(false); return; } } @@ -813,7 +815,7 @@ protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) if (index > -1 && index != aiId) { - SharedStartingLocationNotification(); + await SharedStartingLocationNotificationAsync().ConfigureAwait(false); return; } } @@ -824,13 +826,13 @@ protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) int minPlayers = GameMode.MinPlayersOverride > -1 ? GameMode.MinPlayersOverride : Map.MinPlayers; if (totalPlayerCount < minPlayers) { - InsufficientPlayersNotification(); + await InsufficientPlayersNotificationAsync().ConfigureAwait(false); return; } if (Map.EnforceMaxPlayers && totalPlayerCount > Map.MaxPlayers) { - TooManyPlayersNotification(); + await TooManyPlayersNotificationAsync().ConfigureAwait(false); return; } } @@ -845,82 +847,83 @@ protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) if (!player.Verified) { - NotVerifiedNotification(iId - 1); + await NotVerifiedNotificationAsync(iId - 1).ConfigureAwait(false); return; } - if (player.IsInGame) { - StillInGameNotification(iId - 1); + await StillInGameNotificationAsync(iId - 1).ConfigureAwait(false); return; } - /* - if (DisableSpectatorReadyChecking) - { - // Only account ready status if player is not a spectator - if (!player.Ready && !IsPlayerSpectator(player)) - { - GetReadyNotification(); - return; - } - } - else - { - if (!player.Ready) - { - GetReadyNotification(); - return; - } - } - */ if (!player.Ready) { - GetReadyNotification(); + await GetReadyNotificationAsync().ConfigureAwait(false); return; } - } - HostLaunchGame(); + await HostLaunchGameAsync().ConfigureAwait(false); } - protected virtual void LockGameNotification() => + protected virtual ValueTask LockGameNotificationAsync() + { AddNotice("You need to lock the game room before launching the game.".L10N("Client:Main:LockGameNotification")); - protected virtual void SharedColorsNotification() => + return ValueTask.CompletedTask; + } + + protected virtual ValueTask SharedColorsNotificationAsync() + { AddNotice("Multiple human players cannot share the same color.".L10N("Client:Main:SharedColorsNotification")); - protected virtual void AISpectatorsNotification() => + return ValueTask.CompletedTask; + } + + protected virtual ValueTask AISpectatorsNotificationAsync() + { AddNotice("AI players don't enjoy spectating matches. They want some action!".L10N("Client:Main:AISpectatorsNotification")); - protected virtual void SharedStartingLocationNotification() => + return ValueTask.CompletedTask; + } + + protected virtual ValueTask SharedStartingLocationNotificationAsync() + { AddNotice("Multiple players cannot share the same starting location on this map.".L10N("Client:Main:SharedStartingLocationNotification")); - protected virtual void NotVerifiedNotification(int playerIndex) + return ValueTask.CompletedTask; + } + + protected virtual ValueTask NotVerifiedNotificationAsync(int playerIndex) { if (playerIndex > -1 && playerIndex < Players.Count) AddNotice(string.Format("Unable to launch game. Player {0} hasn't been verified.".L10N("Client:Main:NotVerifiedNotification"), Players[playerIndex].Name)); + + return ValueTask.CompletedTask; } - protected virtual void StillInGameNotification(int playerIndex) + protected virtual ValueTask StillInGameNotificationAsync(int playerIndex) { if (playerIndex > -1 && playerIndex < Players.Count) { - AddNotice(String.Format("Unable to launch game. Player {0} is still playing the game you started previously.".L10N("Client:Main:StillInGameNotification"), + AddNotice(string.Format("Unable to launch game. Player {0} is still playing the game you started previously.".L10N("Client:Main:StillInGameNotification"), Players[playerIndex].Name)); } + + return ValueTask.CompletedTask; } - protected virtual void GetReadyNotification() + protected virtual ValueTask GetReadyNotificationAsync() { AddNotice("The host wants to start the game but cannot because not all players are ready!".L10N("Client:Main:GetReadyNotification")); if (!IsHost && !Players.Find(p => p.Name == ProgramConstants.PLAYERNAME).Ready) sndGetReadySound.Play(); + + return ValueTask.CompletedTask; } - protected virtual void InsufficientPlayersNotification() + protected virtual ValueTask InsufficientPlayersNotificationAsync() { if (GameMode != null && GameMode.MinPlayersOverride > -1) AddNotice(String.Format("Unable to launch game: {0} cannot be played with fewer than {1} players".L10N("Client:Main:InsufficientPlayersNotification1"), @@ -928,42 +931,48 @@ protected virtual void InsufficientPlayersNotification() else if (Map != null) AddNotice(String.Format("Unable to launch game: this map cannot be played with fewer than {0} players.".L10N("Client:Main:InsufficientPlayersNotification2"), Map.MinPlayers)); + + return ValueTask.CompletedTask; } - protected virtual void TooManyPlayersNotification() + protected virtual ValueTask TooManyPlayersNotificationAsync() { if (Map != null) AddNotice(String.Format("Unable to launch game: this map cannot be played with more than {0} players.".L10N("Client:Main:TooManyPlayersNotification"), Map.MaxPlayers)); + + return ValueTask.CompletedTask; } - public virtual void Clear() + public virtual ValueTask ClearAsync(bool exiting) { if (!IsHost) AIPlayers.Clear(); Players.Clear(); + + return ValueTask.CompletedTask; } - protected override void OnGameOptionChanged() + protected override async ValueTask OnGameOptionChangedAsync() { - base.OnGameOptionChanged(); + await base.OnGameOptionChangedAsync().ConfigureAwait(false); ClearReadyStatuses(); CopyPlayerDataToUI(); } - protected abstract void HostLaunchGame(); + protected abstract ValueTask HostLaunchGameAsync(); - protected override void CopyPlayerDataFromUI(object sender, EventArgs e) + protected override async ValueTask CopyPlayerDataFromUIAsync(object sender) { if (PlayerUpdatingInProgress) return; if (IsHost) { - base.CopyPlayerDataFromUI(sender, e); - BroadcastPlayerOptions(); + await base.CopyPlayerDataFromUIAsync(sender).ConfigureAwait(false); + await BroadcastPlayerOptionsAsync().ConfigureAwait(false); return; } @@ -977,7 +986,7 @@ protected override void CopyPlayerDataFromUI(object sender, EventArgs e) int requestedStart = ddPlayerStarts[mTopIndex].SelectedIndex; int requestedTeam = ddPlayerTeams[mTopIndex].SelectedIndex; - RequestPlayerOptions(requestedSide, requestedColor, requestedStart, requestedTeam); + await RequestPlayerOptionsAsync(requestedSide, requestedColor, requestedStart, requestedTeam).ConfigureAwait(false); } protected override void CopyPlayerDataToUI() @@ -998,11 +1007,7 @@ protected override void CopyPlayerDataToUI() // Player statuses for (int pId = 0; pId < Players.Count; pId++) { - /* if (pId != 0 && !Players[pId].Verified) // If player is not verified (not counting the host) - { - StatusIndicators[pId].SwitchTexture("error"); - } - else */ if (Players[pId].IsInGame) // If player is ingame + if (Players[pId].IsInGame) // If player is ingame { StatusIndicators[pId].SwitchTexture(PlayerSlotState.InGame); } @@ -1012,20 +1017,8 @@ protected override void CopyPlayerDataToUI() } else { - // StatusIndicators[pId].SwitchTexture( - // (IsPlayerSpectator(Players[pId]) && DisableSpectatorReadyChecking) - // ? "okDisabled" : "ok"); StatusIndicators[pId].SwitchTexture(Players[pId].Ready ? PlayerSlotState.Ready : PlayerSlotState.NotReady); } - /* - else - { - // StatusIndicators[pId].SwitchTexture( - // (IsPlayerSpectator(Players[pId]) && DisableSpectatorReadyChecking) - // ? "offDisabled" : "off"); - - } - */ UpdatePlayerPingIndicator(Players[pId]); } @@ -1083,13 +1076,13 @@ private Texture2D GetTextureForPing(int ping) } } - protected abstract void BroadcastPlayerOptions(); + protected abstract ValueTask BroadcastPlayerOptionsAsync(); - protected abstract void BroadcastPlayerExtraOptions(); + protected abstract ValueTask BroadcastPlayerExtraOptionsAsync(); - protected abstract void RequestPlayerOptions(int side, int color, int start, int team); + protected abstract ValueTask RequestPlayerOptionsAsync(int side, int color, int start, int team); - protected abstract void RequestReadyStatus(); + protected abstract ValueTask RequestReadyStatusAsync(); // this public as it is used by the main lobby to notify the user of invitation failure public void AddWarning(string message) @@ -1099,21 +1092,18 @@ public void AddWarning(string message) protected override bool AllowPlayerOptionsChange() => IsHost; - protected override void ChangeMap(GameModeMap gameModeMap) + protected override async ValueTask ChangeMapAsync(GameModeMap gameModeMap) { - base.ChangeMap(gameModeMap); + await base.ChangeMapAsync(gameModeMap).ConfigureAwait(false); - bool resetAutoReady = gameModeMap?.GameMode == null || gameModeMap?.Map == null; + bool resetAutoReady = gameModeMap?.GameMode == null || gameModeMap.Map == null; ClearReadyStatuses(resetAutoReady); if ((lastMapChangeWasInvalid || resetAutoReady) && chkAutoReady.Checked) - RequestReadyStatus(); + await RequestReadyStatusAsync().ConfigureAwait(false); lastMapChangeWasInvalid = resetAutoReady; - - //if (IsHost) - // OnGameOptionChanged(); } protected override void ToggleFavoriteMap() diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/PlayerLocationIndicator.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/PlayerLocationIndicator.cs index 1640dd77a..8f6e1d323 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/PlayerLocationIndicator.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/PlayerLocationIndicator.cs @@ -14,7 +14,7 @@ namespace DTAClient.DXGUI.Multiplayer.GameLobby /// /// A player location indicator for the map preview. /// - public class PlayerLocationIndicator : XNAControl + internal sealed class PlayerLocationIndicator : XNAControl { const float TEXTURE_SCALE = 0.25f; diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/SkirmishLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/SkirmishLobby.cs index a94c711ec..22a69bce0 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/SkirmishLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/SkirmishLobby.cs @@ -9,13 +9,14 @@ using ClientGUI; using Rampastring.Tools; using System.IO; +using System.Threading.Tasks; using DTAClient.Domain; using Microsoft.Xna.Framework; using ClientCore.Extensions; namespace DTAClient.DXGUI.Multiplayer.GameLobby { - public class SkirmishLobby : GameLobbyBase, ISwitchable + internal sealed class SkirmishLobby : GameLobbyBase, ISwitchable { private const string SETTINGS_PATH = "Client/SkirmishSettings.ini"; @@ -139,7 +140,7 @@ private string CheckGameValidity() Map.MaxPlayers); } - IEnumerable concatList = Players.Concat(AIPlayers); + List concatList = Players.Concat(AIPlayers).ToList(); foreach (PlayerInfo pInfo in concatList) { @@ -165,29 +166,31 @@ private string CheckGameValidity() return null; } - protected override void BtnLaunchGame_LeftClick(object sender, EventArgs e) + protected override async ValueTask BtnLaunchGame_LeftClickAsync() { string error = CheckGameValidity(); if (error == null) { SaveSettings(); - StartGame(); + await StartGameAsync().ConfigureAwait(false); return; } XNAMessageBox.Show(WindowManager, "Cannot launch game".L10N("Client:Main:LaunchGameErrorTitle"), error); } - protected override void BtnLeaveGame_LeftClick(object sender, EventArgs e) + protected override ValueTask BtnLeaveGame_LeftClickAsync() { - this.Enabled = false; - this.Visible = false; + Enabled = false; + Visible = false; Exited?.Invoke(this, EventArgs.Empty); topBar.RemovePrimarySwitchable(this); ResetDiscordPresence(); + + return ValueTask.CompletedTask; } private void PlayerSideChanged(object sender, EventArgs e) @@ -225,11 +228,10 @@ protected override int GetDefaultMapRankIndex(GameModeMap gameModeMap) return StatisticsManager.Instance.GetSkirmishRankForDefaultMap(gameModeMap.Map.UntranslatedName, gameModeMap.Map.MaxPlayers); } - protected override void GameProcessExited() + protected override async ValueTask GameProcessExitedAsync() { - base.GameProcessExited(); - - DdGameModeMapFilter_SelectedIndexChanged(null, EventArgs.Empty); // Refresh ranks + await base.GameProcessExitedAsync().ConfigureAwait(false); + await DdGameModeMapFilter_SelectedIndexChangedAsync().ConfigureAwait(false); // Refresh ranks RandomSeed = new Random().Next(); } @@ -296,7 +298,7 @@ private void SaveSettings() } catch (Exception ex) { - Logger.Log("Saving skirmish settings failed! Reason: " + ex.Message); + ProgramConstants.LogException(ex, "Saving skirmish settings failed!"); } } diff --git a/DXMainClient/DXGUI/Multiplayer/LANGameLoadingLobby.cs b/DXMainClient/DXGUI/Multiplayer/LANGameLoadingLobby.cs index 3c3635524..0b1a71d4e 100644 --- a/DXMainClient/DXGUI/Multiplayer/LANGameLoadingLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/LANGameLoadingLobby.cs @@ -10,33 +10,28 @@ using Rampastring.Tools; using Rampastring.XNAUI; using System; +using System.Buffers; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.DXGUI.Multiplayer { - class LANGameLoadingLobby : GameLoadingLobbyBase + internal sealed class LANGameLoadingLobby : GameLoadingLobbyBase { private const double DROPOUT_TIMEOUT = 20.0; private const double GAME_BROADCAST_INTERVAL = 10.0; - private const string OPTIONS_COMMAND = "OPTS"; - private const string GAME_LAUNCH_COMMAND = "START"; - private const string READY_STATUS_COMMAND = "READY"; - private const string CHAT_COMMAND = "CHAT"; - private const string PLAYER_QUIT_COMMAND = "QUIT"; - private const string PLAYER_JOIN_COMMAND = "JOIN"; - private const string FILE_HASH_COMMAND = "FHASH"; - public LANGameLoadingLobby( WindowManager windowManager, LANColor[] chatColors, MapLoader mapLoader, - DiscordHandler discordHandler - ) : base(windowManager, discordHandler) + DiscordHandler discordHandler) + : base(windowManager, discordHandler) { encoding = ProgramConstants.LAN_ENCODING; this.chatColors = chatColors; @@ -46,41 +41,39 @@ DiscordHandler discordHandler hostCommandHandlers = new LANServerCommandHandler[] { - new ServerStringCommandHandler(CHAT_COMMAND, Server_HandleChatMessage), - new ServerStringCommandHandler(FILE_HASH_COMMAND, Server_HandleFileHashMessage), - new ServerNoParamCommandHandler(READY_STATUS_COMMAND, Server_HandleReadyRequest), + new ServerStringCommandHandler(LANCommands.CHAT_GAME_LOADING_COMMAND, (sender, data) => Server_HandleChatMessageAsync(sender, data).HandleTask()), + new ServerStringCommandHandler(LANCommands.FILE_HASH, Server_HandleFileHashMessage), + new ServerNoParamCommandHandler(LANCommands.READY_STATUS, sender => Server_HandleReadyRequestAsync(sender).HandleTask()) }; playerCommandHandlers = new LANClientCommandHandler[] { - new ClientStringCommandHandler(CHAT_COMMAND, Client_HandleChatMessage), - new ClientStringCommandHandler(OPTIONS_COMMAND, Client_HandleOptionsMessage), - new ClientNoParamCommandHandler(GAME_LAUNCH_COMMAND, Client_HandleStartCommand) + new ClientStringCommandHandler(LANCommands.CHAT_GAME_LOADING_COMMAND, Client_HandleChatMessage), + new ClientStringCommandHandler(LANCommands.OPTIONS, Client_HandleOptionsMessage), + new ClientNoParamCommandHandler(LANCommands.GAME_START, () => Client_HandleStartCommandAsync().HandleTask()) }; - WindowManager.GameClosing += WindowManager_GameClosing; + WindowManager.GameClosing += (_, _) => WindowManager_GameClosingAsync().HandleTask(); } - private void WindowManager_GameClosing(object sender, EventArgs e) + private async ValueTask WindowManager_GameClosingAsync() { - if (client != null && client.Connected) - Clear(); + if (client is { Connected: true }) + await ClearAsync().ConfigureAwait(false); } - public event EventHandler LobbyNotification; public event EventHandler GameBroadcast; - private TcpListener listener; - private TcpClient client; + private Socket listener; + private Socket client; - private IPEndPoint hostEndPoint; - private LANColor[] chatColors; + private readonly LANColor[] chatColors; private readonly MapLoader mapLoader; - private int chatColorIndex; - private Encoding encoding; + private const int chatColorIndex = 0; + private readonly Encoding encoding; - private LANServerCommandHandler[] hostCommandHandlers; - private LANClientCommandHandler[] playerCommandHandlers; + private readonly LANServerCommandHandler[] hostCommandHandlers; + private readonly LANClientCommandHandler[] playerCommandHandlers; private TimeSpan timeSinceGameBroadcast = TimeSpan.Zero; @@ -88,7 +81,7 @@ private void WindowManager_GameClosing(object sender, EventArgs e) private string overMessage = string.Empty; - private string localGame; + private readonly string localGame; private string localFileHash; @@ -96,34 +89,41 @@ private void WindowManager_GameClosing(object sender, EventArgs e) private int loadedGameId; - private bool started = false; + private bool started; - public void SetUp(bool isHost, - IPEndPoint hostEndPoint, TcpClient client, - int loadedGameId) + private CancellationTokenSource cancellationTokenSource; + + public async ValueTask SetUpAsync(bool isHost, Socket client, int loadedGameId) { Refresh(isHost); - this.hostEndPoint = hostEndPoint; - this.loadedGameId = loadedGameId; started = false; + cancellationTokenSource?.Dispose(); + cancellationTokenSource = new CancellationTokenSource(); + if (isHost) { - Thread thread = new Thread(ListenForClients); - thread.Start(); + ListenForClientsAsync(cancellationTokenSource.Token).HandleTask(); + + this.client = new Socket(SocketType.Stream, ProtocolType.Tcp); + await this.client.ConnectAsync(IPAddress.Loopback, ProgramConstants.LAN_GAME_LOBBY_PORT).ConfigureAwait(false); + + string message = LANCommands.PLAYER_JOIN + + ProgramConstants.LAN_DATA_SEPARATOR + ProgramConstants.PLAYERNAME + + ProgramConstants.LAN_DATA_SEPARATOR + loadedGameId; - this.client = new TcpClient(); - this.client.Connect("127.0.0.1", ProgramConstants.LAN_GAME_LOBBY_PORT); + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); - byte[] buffer = encoding.GetBytes(PLAYER_JOIN_COMMAND + - ProgramConstants.LAN_DATA_SEPARATOR + ProgramConstants.PLAYERNAME + - ProgramConstants.LAN_DATA_SEPARATOR + loadedGameId); + buffer = buffer[..bytes]; - this.client.GetStream().Write(buffer, 0, buffer.Length); - this.client.GetStream().Flush(); + await this.client.SendAsync(buffer, SocketFlags.None, CancellationToken.None).ConfigureAwait(false); var fhc = new FileHashCalculator(); fhc.CalculateHashes(gameModes); @@ -131,10 +131,11 @@ public void SetUp(bool isHost, } else { + this.client?.Dispose(); this.client = client; } - new Thread(HandleServerCommunication).Start(); + HandleServerCommunicationAsync(cancellationTokenSource.Token).HandleTask(); if (IsHost) CopyPlayerDataToUI(); @@ -142,62 +143,70 @@ public void SetUp(bool isHost, WindowManager.SelectedControl = tbChatInput; } - public void PostJoin() + public async ValueTask PostJoinAsync() { var fhc = new FileHashCalculator(); fhc.CalculateHashes(gameModes); - SendMessageToHost(FILE_HASH_COMMAND + " " + fhc.GetCompleteHash()); + await SendMessageToHostAsync(LANCommands.FILE_HASH + " " + fhc.GetCompleteHash(), cancellationTokenSource?.Token ?? default).ConfigureAwait(false); UpdateDiscordPresence(true); } #region Server code - private void ListenForClients() + private async ValueTask ListenForClientsAsync(CancellationToken cancellationToken) { - listener = new TcpListener(IPAddress.Any, ProgramConstants.LAN_GAME_LOBBY_PORT); - listener.Start(); + listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.IPv6Any, ProgramConstants.LAN_GAME_LOBBY_PORT)); + listener.Listen(); - while (true) + while (!cancellationToken.IsCancellationRequested) { - TcpClient client; + Socket newPlayerSocket; try { - client = listener.AcceptTcpClient(); + newPlayerSocket = await listener.AcceptAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Listener error: " + ex.Message); + ProgramConstants.LogException(ex, "Listener error."); break; } - Logger.Log("New client connected from " + ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString()); + Logger.Log("New client connected from " + ((IPEndPoint)newPlayerSocket.RemoteEndPoint).Address); LANPlayerInfo lpInfo = new LANPlayerInfo(encoding); - lpInfo.SetClient(client); + lpInfo.SetClient(newPlayerSocket); - Thread thread = new Thread(new ParameterizedThreadStart(HandleClientConnection)); - thread.Start(lpInfo); + HandleClientConnectionAsync(lpInfo, cancellationToken).HandleTask(); } } - private void HandleClientConnection(object clientInfo) + private async ValueTask HandleClientConnectionAsync(LANPlayerInfo lpInfo, CancellationToken cancellationToken) { - var lpInfo = (LANPlayerInfo)clientInfo; - - byte[] message = new byte[1024]; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(4096); - while (true) + while (!cancellationToken.IsCancellationRequested) { - int bytesRead = 0; + int bytesRead; + Memory message; try { - bytesRead = lpInfo.TcpClient.GetStream().Read(message, 0, message.Length); + message = memoryOwner.Memory[..4096]; + bytesRead = await client.ReceiveAsync(message, SocketFlags.None, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Socket error with client " + lpInfo.IPAddress + "; removing. Message: " + ex.Message); + ProgramConstants.LogException(ex, "Socket error with client " + lpInfo.IPAddress + "; removing."); break; } @@ -208,8 +217,7 @@ private void HandleClientConnection(object clientInfo) break; } - string msg = encoding.GetString(message, 0, bytesRead); - + string msg = encoding.GetString(message.Span[..bytesRead]); string[] command = msg.Split(ProgramConstants.LAN_MESSAGE_SEPARATOR); string[] parts = command[0].Split(ProgramConstants.LAN_DATA_SEPARATOR); @@ -219,28 +227,29 @@ private void HandleClientConnection(object clientInfo) string name = parts[1].Trim(); int loadedGameId = Conversions.IntFromString(parts[2], -1); - if (parts[0] == "JOIN" && !string.IsNullOrEmpty(name) + if (parts[0] == LANCommands.PLAYER_JOIN && !string.IsNullOrEmpty(name) && loadedGameId == this.loadedGameId) { lpInfo.Name = name; - AddCallback(new Action(AddPlayer), lpInfo); + AddCallback(() => AddPlayerAsync(lpInfo, cancellationToken).HandleTask()); return; } break; } - if (lpInfo.TcpClient.Connected) - lpInfo.TcpClient.Close(); + lpInfo.TcpClient.Shutdown(SocketShutdown.Both); + lpInfo.TcpClient.Close(); } - private void AddPlayer(LANPlayerInfo lpInfo) + private async ValueTask AddPlayerAsync(LANPlayerInfo lpInfo, CancellationToken cancellationToken) { if (Players.Find(p => p.Name == lpInfo.Name) != null || Players.Count >= SGPlayers.Count || SGPlayers.Find(p => p.Name == lpInfo.Name) == null) { + lpInfo.TcpClient.Shutdown(SocketShutdown.Both); lpInfo.TcpClient.Close(); return; } @@ -251,19 +260,19 @@ private void AddPlayer(LANPlayerInfo lpInfo) Players.Add(lpInfo); lpInfo.MessageReceived += LpInfo_MessageReceived; - lpInfo.ConnectionLost += LpInfo_ConnectionLost; + lpInfo.ConnectionLost += (sender, _) => LpInfo_ConnectionLostAsync(sender).HandleTask(); sndJoinSound.Play(); AddNotice(string.Format("{0} connected from {1}".L10N("Client:Main:PlayerFromIP"), lpInfo.Name, lpInfo.IPAddress)); - lpInfo.StartReceiveLoop(); + lpInfo.StartReceiveLoopAsync(cancellationToken).HandleTask(); CopyPlayerDataToUI(); - BroadcastOptions(); + await BroadcastOptionsAsync().ConfigureAwait(false); UpdateDiscordPresence(); } - private void LpInfo_ConnectionLost(object sender, EventArgs e) + private async ValueTask LpInfo_ConnectionLostAsync(object sender) { var lpInfo = (LANPlayerInfo)sender; CleanUpPlayer(lpInfo); @@ -274,14 +283,13 @@ private void LpInfo_ConnectionLost(object sender, EventArgs e) sndLeaveSound.Play(); CopyPlayerDataToUI(); - BroadcastOptions(); + await BroadcastOptionsAsync().ConfigureAwait(false); UpdateDiscordPresence(); } private void LpInfo_MessageReceived(object sender, NetworkMessageEventArgs e) { - AddCallback(new Action(HandleClientMessage), - e.Message, (LANPlayerInfo)sender); + AddCallback(() => HandleClientMessage(e.Message, (LANPlayerInfo)sender)); } private void HandleClientMessage(string data, LANPlayerInfo lpInfo) @@ -294,50 +302,55 @@ private void HandleClientMessage(string data, LANPlayerInfo lpInfo) return; } - Logger.Log("Unknown LAN command from " + lpInfo.ToString() + " : " + data); + Logger.Log("Unknown LAN command from " + lpInfo + " : " + data); } private void CleanUpPlayer(LANPlayerInfo lpInfo) { lpInfo.MessageReceived -= LpInfo_MessageReceived; + + if (lpInfo.TcpClient.Connected) + lpInfo.TcpClient.Shutdown(SocketShutdown.Both); + lpInfo.TcpClient.Close(); } #endregion - private void HandleServerCommunication() + private async ValueTask HandleServerCommunicationAsync(CancellationToken cancellationToken) { - byte[] message = new byte[1024]; - - var msg = string.Empty; - - int bytesRead = 0; - if (!client.Connected) return; - var stream = client.GetStream(); + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(4096); - while (true) + while (!cancellationToken.IsCancellationRequested) { - bytesRead = 0; + int bytesRead; + Memory message; try { - bytesRead = stream.Read(message, 0, message.Length); + message = memoryOwner.Memory[..4096]; + bytesRead = await client.ReceiveAsync(message, SocketFlags.None, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Reading data from the server failed! Message: " + ex.Message); - LeaveGame(); + ProgramConstants.LogException(ex, "Reading data from the server failed!"); + await LeaveGameAsync().ConfigureAwait(false); break; } if (bytesRead > 0) { - msg = encoding.GetString(message, 0, bytesRead); + string msg = encoding.GetString(message.Span[..bytesRead]); msg = overMessage + msg; + List commands = new List(); while (true) @@ -349,23 +362,21 @@ private void HandleServerCommunication() overMessage = msg; break; } - else - { - commands.Add(msg.Substring(0, index)); - msg = msg.Substring(index + 1); - } + + commands.Add(msg[..index]); + msg = msg[(index + 1)..]; } foreach (string cmd in commands) { - AddCallback(new Action(HandleMessageFromServer), cmd); + AddCallback(() => HandleMessageFromServer(cmd)); } continue; } Logger.Log("Reading data from the server failed (0 bytes received)!"); - LeaveGame(); + await LeaveGameAsync().ConfigureAwait(false); break; } } @@ -383,30 +394,30 @@ private void HandleMessageFromServer(string message) Logger.Log("Unknown LAN command from the server: " + message); } - protected override void LeaveGame() + protected override async ValueTask LeaveGameAsync() { - Clear(); + await ClearAsync().ConfigureAwait(false); Disable(); - - base.LeaveGame(); + await base.LeaveGameAsync().ConfigureAwait(false); } - private void Clear() + private async ValueTask ClearAsync() { if (IsHost) { - BroadcastMessage(PLAYER_QUIT_COMMAND); + await BroadcastMessageAsync(LANCommands.PLAYER_QUIT_COMMAND, CancellationToken.None).ConfigureAwait(false); Players.ForEach(p => CleanUpPlayer((LANPlayerInfo)p)); Players.Clear(); - listener.Stop(); + listener.Close(); } else { - SendMessageToHost(PLAYER_QUIT_COMMAND); + await SendMessageToHostAsync(LANCommands.PLAYER_QUIT_COMMAND, CancellationToken.None).ConfigureAwait(false); } - if (this.client.Connected) - this.client.Close(); + cancellationTokenSource.Cancel(); + client.Shutdown(SocketShutdown.Both); + client.Close(); } protected override void AddNotice(string message, Color color) @@ -414,12 +425,12 @@ protected override void AddNotice(string message, Color color) lbChatMessages.AddMessage(null, message, color); } - protected override void BroadcastOptions() + protected override async ValueTask BroadcastOptionsAsync() { if (Players.Count > 0) Players[0].Ready = true; - var sb = new ExtendedStringBuilder(OPTIONS_COMMAND + " ", true); + var sb = new ExtendedStringBuilder(LANCommands.OPTIONS + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; sb.Append(ddSavedGame.SelectedIndex); @@ -431,30 +442,26 @@ protected override void BroadcastOptions() sb.Append(pInfo.IPAddress); } - BroadcastMessage(sb.ToString()); + await BroadcastMessageAsync(sb.ToString(), cancellationTokenSource?.Token ?? default).ConfigureAwait(false); } - protected override void HostStartGame() - { - BroadcastMessage(GAME_LAUNCH_COMMAND); - } + protected override ValueTask HostStartGameAsync() + => BroadcastMessageAsync(LANCommands.GAME_START, cancellationTokenSource?.Token ?? default); - protected override void RequestReadyStatus() - { - SendMessageToHost(READY_STATUS_COMMAND); - } + protected override ValueTask RequestReadyStatusAsync() + => SendMessageToHostAsync(LANCommands.READY_STATUS, cancellationTokenSource?.Token ?? default); - protected override void SendChatMessage(string message) + protected override async ValueTask SendChatMessageAsync(string message) { - SendMessageToHost(CHAT_COMMAND + " " + chatColorIndex + - ProgramConstants.LAN_DATA_SEPARATOR + message); + await SendMessageToHostAsync(LANCommands.CHAT_GAME_LOADING_COMMAND + " " + chatColorIndex + + ProgramConstants.LAN_DATA_SEPARATOR + message, cancellationTokenSource?.Token ?? default).ConfigureAwait(false); sndMessageSound.Play(); } #region Server's command handlers - private void Server_HandleChatMessage(LANPlayerInfo sender, string data) + private async ValueTask Server_HandleChatMessageAsync(LANPlayerInfo sender, string data) { string[] parts = data.Split(ProgramConstants.LAN_DATA_SEPARATOR); @@ -466,9 +473,9 @@ private void Server_HandleChatMessage(LANPlayerInfo sender, string data) if (colorIndex < 0 || colorIndex >= chatColors.Length) return; - BroadcastMessage(CHAT_COMMAND + " " + sender + + await BroadcastMessageAsync(LANCommands.CHAT_GAME_LOADING_COMMAND + " " + sender + ProgramConstants.LAN_DATA_SEPARATOR + colorIndex + - ProgramConstants.LAN_DATA_SEPARATOR + data); + ProgramConstants.LAN_DATA_SEPARATOR + data, cancellationTokenSource?.Token ?? default).ConfigureAwait(false); } private void Server_HandleFileHashMessage(LANPlayerInfo sender, string hash) @@ -478,14 +485,14 @@ private void Server_HandleFileHashMessage(LANPlayerInfo sender, string hash) sender.Verified = true; } - private void Server_HandleReadyRequest(LANPlayerInfo sender) + private async ValueTask Server_HandleReadyRequestAsync(LANPlayerInfo sender) { - if (!sender.Ready) - { - sender.Ready = true; - CopyPlayerDataToUI(); - BroadcastOptions(); - } + if (sender.Ready) + return; + + sender.Ready = true; + CopyPlayerDataToUI(); + await BroadcastOptionsAsync().ConfigureAwait(false); } #endregion @@ -521,7 +528,7 @@ private void Client_HandleOptionsMessage(string data) const int PLAYER_INFO_PARTS = 3; int pCount = (parts.Length - 1) / PLAYER_INFO_PARTS; - if (pCount * PLAYER_INFO_PARTS + 1 != parts.Length) + if ((pCount * PLAYER_INFO_PARTS) + 1 != parts.Length) return; int savedGameIndex = Conversions.IntFromString(parts[0], -1); @@ -536,29 +543,27 @@ private void Client_HandleOptionsMessage(string data) for (int i = 0; i < pCount; i++) { - int baseIndex = 1 + i * PLAYER_INFO_PARTS; - string pName = parts[baseIndex]; - bool ready = Conversions.IntFromString(parts[baseIndex + 1], -1) > 0; - string ipAddress = parts[baseIndex + 2]; - - LANPlayerInfo pInfo = new LANPlayerInfo(encoding); - pInfo.Name = pName; - pInfo.Ready = ready; - pInfo.IPAddress = ipAddress; - Players.Add(pInfo); + int baseIndex = 1 + (i * PLAYER_INFO_PARTS); + + Players.Add(new LANPlayerInfo(encoding) + { + Name = parts[baseIndex], + Ready = Conversions.IntFromString(parts[baseIndex + 1], -1) > 0, + IPAddress = IPAddress.Parse(parts[baseIndex + 2]) + }); } if (Players.Count > 0) // Set IP of host - Players[0].IPAddress = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString(); + Players[0].IPAddress = ((IPEndPoint)client.RemoteEndPoint).Address; CopyPlayerDataToUI(); } - private void Client_HandleStartCommand() + private ValueTask Client_HandleStartCommandAsync() { started = true; - LoadGame(); + return LoadGameAsync(); } #endregion @@ -567,7 +572,7 @@ private void Client_HandleStartCommand() /// Broadcasts a command to all players in the game as the game host. /// /// The command to send. - private void BroadcastMessage(string message) + private async ValueTask BroadcastMessageAsync(string message, CancellationToken cancellationToken) { if (!IsHost) return; @@ -575,40 +580,40 @@ private void BroadcastMessage(string message) foreach (PlayerInfo pInfo in Players) { var lpInfo = (LANPlayerInfo)pInfo; - lpInfo.SendMessage(message); + await lpInfo.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); } } - private void SendMessageToHost(string message) + private async ValueTask SendMessageToHostAsync(string message, CancellationToken cancellationToken) { if (!client.Connected) return; - byte[] buffer = encoding.GetBytes( - message + ProgramConstants.LAN_MESSAGE_SEPARATOR); + message += ProgramConstants.LAN_MESSAGE_SEPARATOR; - NetworkStream ns = client.GetStream(); + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + + buffer = buffer[..bytes]; try { - ns.Write(buffer, 0, buffer.Length); - ns.Flush(); + await client.SendAsync(buffer, SocketFlags.None, cancellationToken).ConfigureAwait(false); } - catch + catch (OperationCanceledException) { - Logger.Log("Sending message to game host failed!"); } - } - - public void SetChatColorIndex(int colorIndex) - { - chatColorIndex = colorIndex; + catch (Exception ex) + { + ProgramConstants.LogException(ex, "Sending message to game host failed!"); + } } public override string GetSwitchName() - { - return "Load Game".L10N("Client:Main:LoadGameSwitchName"); - } + => "Load Game".L10N("Client:Main:LoadGameSwitchName"); public override void Update(GameTime gameTime) { @@ -617,13 +622,13 @@ public override void Update(GameTime gameTime) for (int i = 1; i < Players.Count; i++) { LANPlayerInfo lpInfo = (LANPlayerInfo)Players[i]; - if (!lpInfo.Update(gameTime)) + if (!Task.Run(() => lpInfo.UpdateAsync(gameTime).HandleTask(true)).Result) { CleanUpPlayer(lpInfo); Players.RemoveAt(i); AddNotice(string.Format("{0} - connection timed out".L10N("Client:Main:PlayerTimeout"), lpInfo.Name)); CopyPlayerDataToUI(); - BroadcastOptions(); + Task.Run(() => BroadcastOptionsAsync().HandleTask()).Wait(); UpdateDiscordPresence(); i--; } @@ -642,11 +647,7 @@ public override void Update(GameTime gameTime) timeSinceLastReceivedCommand += gameTime.ElapsedGameTime; if (timeSinceLastReceivedCommand > TimeSpan.FromSeconds(DROPOUT_TIMEOUT)) - { - LobbyNotification?.Invoke(this, - new LobbyNotificationEventArgs("Connection to the game host timed out.".L10N("Client:Main:HostConnectTimeOut"))); - LeaveGame(); - } + Task.Run(() => LeaveGameAsync().HandleTask()).Wait(); } base.Update(gameTime); @@ -654,7 +655,7 @@ public override void Update(GameTime gameTime) private void BroadcastGame() { - var sb = new ExtendedStringBuilder("GAME ", true); + var sb = new ExtendedStringBuilder(LANCommands.GAME + " ", true); sb.Separator = ProgramConstants.LAN_DATA_SEPARATOR; sb.Append(ProgramConstants.LAN_PROTOCOL_REVISION); sb.Append(ProgramConstants.GAME_VERSION); @@ -672,11 +673,10 @@ private void BroadcastGame() GameBroadcast?.Invoke(this, new GameBroadcastEventArgs(sb.ToString())); } - protected override void HandleGameProcessExited() + protected override async ValueTask HandleGameProcessExitedAsync() { - base.HandleGameProcessExited(); - - LeaveGame(); + await base.HandleGameProcessExitedAsync().ConfigureAwait(false); + await LeaveGameAsync().ConfigureAwait(false); } protected override void UpdateDiscordPresence(bool resetTimer = false) @@ -695,4 +695,4 @@ protected override void UpdateDiscordPresence(bool resetTimer = false) "LAN Game", IsHost, resetTimer); } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Multiplayer/LANLobby.cs b/DXMainClient/DXGUI/Multiplayer/LANLobby.cs index 3cfdec299..68a033c90 100644 --- a/DXMainClient/DXGUI/Multiplayer/LANLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/LANLobby.cs @@ -1,5 +1,20 @@ -using ClientCore; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; using ClientCore.CnCNet5; +using ClientCore.Extensions; using ClientGUI; using DTAClient.Domain; using DTAClient.Domain.LAN; @@ -13,33 +28,23 @@ using Rampastring.Tools; using Rampastring.XNAUI; using Rampastring.XNAUI.XNAControls; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Reflection; -using System.Text; -using System.Threading; using SixLabors.ImageSharp; using Color = Microsoft.Xna.Framework.Color; using Rectangle = Microsoft.Xna.Framework.Rectangle; namespace DTAClient.DXGUI.Multiplayer { - class LANLobby : XNAWindow + internal sealed class LANLobby : XNAWindow { private const double ALIVE_MESSAGE_INTERVAL = 5.0; private const double INACTIVITY_REMOVE_TIME = 10.0; - private const double GAME_INACTIVITY_REMOVE_TIME = 20.0; public LANLobby( WindowManager windowManager, GameCollection gameCollection, MapLoader mapLoader, - DiscordHandler discordHandler - ) : base(windowManager) + DiscordHandler discordHandler) + : base(windowManager) { this.gameCollection = gameCollection; this.mapLoader = mapLoader; @@ -48,54 +53,33 @@ DiscordHandler discordHandler public event EventHandler Exited; - XNAListBox lbPlayerList; - ChatListBox lbChatMessages; - GameListBox lbGameList; - - XNAClientButton btnMainMenu; - XNAClientButton btnNewGame; - XNAClientButton btnJoinGame; - - XNAChatTextBox tbChatInput; - - XNALabel lblColor; - - XNAClientDropDown ddColor; - - LANGameCreationWindow gameCreationWindow; - - LANGameLobby lanGameLobby; - - LANGameLoadingLobby lanGameLoadingLobby; - - Texture2D unknownGameIcon; - - LANColor[] chatColors; - - string localGame; - int localGameIndex; - - GameCollection gameCollection; - - private List gameModes => mapLoader.GameModes; - - TimeSpan timeSinceGameRefresh = TimeSpan.Zero; - - EnhancedSoundEffect sndGameCreated; - - Socket socket; - IPEndPoint endPoint; - Encoding encoding; - - List players = new List(); - - TimeSpan timeSinceAliveMessage = TimeSpan.Zero; - - MapLoader mapLoader; - - DiscordHandler discordHandler; - - bool initSuccess = false; + private readonly List<(Socket Socket, IPEndPoint BroadcastIpEndpoint)> sockets = new(); + private readonly IPEndPoint loopBackIpEndPoint = new(IPAddress.Loopback, ProgramConstants.LAN_LOBBY_PORT); + + private XNAListBox lbPlayerList; + private ChatListBox lbChatMessages; + private GameListBox lbGameList; + private XNAClientButton btnMainMenu; + private XNAClientButton btnNewGame; + private XNAClientButton btnJoinGame; + private XNAChatTextBox tbChatInput; + private XNALabel lblColor; + private XNAClientDropDown ddColor; + private LANGameCreationWindow gameCreationWindow; + private LANGameLobby lanGameLobby; + private LANGameLoadingLobby lanGameLoadingLobby; + private Texture2D unknownGameIcon; + private LANColor[] chatColors; + private string localGame; + private int localGameIndex; + private GameCollection gameCollection; + private Encoding encoding; + private List players = new List(); + private TimeSpan timeSinceAliveMessage = TimeSpan.Zero; + private MapLoader mapLoader; + private DiscordHandler discordHandler; + private bool initSuccess; + private CancellationTokenSource cancellationTokenSource; public override void Initialize() { @@ -105,28 +89,27 @@ public override void Initialize() WindowManager.RenderResolutionY - 64); localGame = ClientConfiguration.Instance.LocalGame; - localGameIndex = gameCollection.GameList.FindIndex( - g => g.InternalName.ToUpper() == localGame.ToUpper()); + localGameIndex = gameCollection.GameList.FindIndex(g => g.InternalName.Equals(localGame, StringComparison.InvariantCultureIgnoreCase)); btnNewGame = new XNAClientButton(WindowManager); btnNewGame.Name = "btnNewGame"; btnNewGame.ClientRectangle = new Rectangle(12, Height - 35, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnNewGame.Text = "Create Game".L10N("Client:Main:CreateGame"); - btnNewGame.LeftClick += BtnNewGame_LeftClick; + btnNewGame.LeftClick += (_, _) => BtnNewGame_LeftClickAsync().HandleTask(); btnJoinGame = new XNAClientButton(WindowManager); btnJoinGame.Name = "btnJoinGame"; btnJoinGame.ClientRectangle = new Rectangle(btnNewGame.Right + 12, btnNewGame.Y, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnJoinGame.Text = "Join Game".L10N("Client:Main:JoinGame"); - btnJoinGame.LeftClick += BtnJoinGame_LeftClick; + btnJoinGame.LeftClick += (_, _) => JoinGameAsync().HandleTask(); btnMainMenu = new XNAClientButton(WindowManager); btnMainMenu.Name = "btnMainMenu"; btnMainMenu.ClientRectangle = new Rectangle(Width - 145, btnNewGame.Y, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnMainMenu.Text = "Main Menu".L10N("Client:Main:MainMenu"); - btnMainMenu.LeftClick += BtnMainMenu_LeftClick; + btnMainMenu.LeftClick += (_, _) => BtnMainMenu_LeftClickAsync().HandleTask(); lbGameList = new GameListBox(WindowManager, mapLoader, localGame); lbGameList.Name = "lbGameList"; @@ -136,7 +119,7 @@ public override void Initialize() lbGameList.GameLifetime = 15.0; // Smaller lifetime in LAN lbGameList.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; lbGameList.BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 128), 1, 1); - lbGameList.DoubleLeftClick += LbGameList_DoubleLeftClick; + lbGameList.DoubleLeftClick += (_, _) => JoinGameAsync().HandleTask(); lbGameList.AllowMultiLineItems = false; lbPlayerList = new XNAListBox(WindowManager); @@ -165,7 +148,7 @@ public override void Initialize() btnNewGame.Height); tbChatInput.Suggestion = "Type here to chat...".L10N("Client:Main:ChatHere"); tbChatInput.MaximumTextLength = 200; - tbChatInput.EnterPressed += TbChatInput_EnterPressed; + tbChatInput.EnterPressed += (_, _) => TbChatInput_EnterPressedAsync(cancellationTokenSource?.Token ?? default).HandleTask(); lblColor = new XNALabel(WindowManager); lblColor.Name = "lblColor"; @@ -219,16 +202,14 @@ public override void Initialize() gameCreationPanel.AddChild(gameCreationWindow); gameCreationWindow.Disable(); - gameCreationWindow.NewGame += GameCreationWindow_NewGame; - gameCreationWindow.LoadGame += GameCreationWindow_LoadGame; + gameCreationWindow.NewGame += (_, _) => GameCreationWindow_NewGameAsync().HandleTask(); + gameCreationWindow.LoadGame += (_, e) => GameCreationWindow_LoadGameAsync(e).HandleTask(); var assembly = Assembly.GetAssembly(typeof(GameCollection)); using Stream unknownIconStream = assembly.GetManifestResourceStream("ClientCore.Resources.unknownicon.png"); unknownGameIcon = AssetLoader.TextureFromImage(Image.Load(unknownIconStream)); - sndGameCreated = new EnhancedSoundEffect("gamecreated.wav"); - encoding = Encoding.UTF8; base.Initialize(); @@ -254,67 +235,40 @@ public override void Initialize() SetChatColor(); ddColor.SelectedIndexChanged += DdColor_SelectedIndexChanged; - lanGameLobby.GameLeft += LanGameLobby_GameLeft; - lanGameLobby.GameBroadcast += LanGameLobby_GameBroadcast; + lanGameLobby.GameLeft += (_, _) => Enable(); + lanGameLobby.GameBroadcast += (_, e) => SendMessageAsync(e.Message, cancellationTokenSource?.Token ?? default).HandleTask(); - lanGameLoadingLobby.GameBroadcast += LanGameLoadingLobby_GameBroadcast; - lanGameLoadingLobby.GameLeft += LanGameLoadingLobby_GameLeft; + lanGameLoadingLobby.GameBroadcast += (_, e) => SendMessageAsync(e.Message, cancellationTokenSource?.Token ?? default).HandleTask(); + lanGameLoadingLobby.GameLeft += (_, _) => Enable(); - WindowManager.GameClosing += WindowManager_GameClosing; + WindowManager.GameClosing += (_, _) => WindowManager_GameClosingAsync(cancellationTokenSource?.Token ?? default).HandleTask(); } - private void LanGameLoadingLobby_GameLeft(object sender, EventArgs e) + private async ValueTask WindowManager_GameClosingAsync(CancellationToken cancellationToken) { - Enable(); - } - - private void WindowManager_GameClosing(object sender, EventArgs e) - { - if (socket == null) - return; - - if (socket.IsBound) + foreach ((Socket socket, _) in sockets) { - try - { - SendMessage("QUIT"); - socket.Close(); - } - catch (ObjectDisposedException) - { - - } + if (socket.IsBound) + await SendMessageAsync(LANCommands.PLAYER_QUIT_COMMAND, cancellationToken).ConfigureAwait(false); } - } - private void LanGameLobby_GameBroadcast(object sender, GameBroadcastEventArgs e) - { - SendMessage(e.Message); - } + cancellationTokenSource?.Cancel(); - private void LanGameLobby_GameLeft(object sender, EventArgs e) - { - Enable(); + foreach ((Socket socket, _) in sockets) + socket.Close(); } - private void LanGameLoadingLobby_GameBroadcast(object sender, GameBroadcastEventArgs e) + private async ValueTask GameCreationWindow_LoadGameAsync(GameLoadEventArgs e) { - SendMessage(e.Message); - } - - private void GameCreationWindow_LoadGame(object sender, GameLoadEventArgs e) - { - lanGameLoadingLobby.SetUp(true, - new IPEndPoint(IPAddress.Loopback, ProgramConstants.LAN_GAME_LOBBY_PORT), - null, e.LoadedGameID); + await lanGameLoadingLobby.SetUpAsync(true, null, e.LoadedGameID).ConfigureAwait(false); lanGameLoadingLobby.Enable(); } - private void GameCreationWindow_NewGame(object sender, EventArgs e) + private async ValueTask GameCreationWindow_NewGameAsync() { - lanGameLobby.SetUp(true, - new IPEndPoint(IPAddress.Loopback, ProgramConstants.LAN_GAME_LOBBY_PORT), null); + await lanGameLobby.SetUpAsync(true, + new IPEndPoint(IPAddress.Loopback, ProgramConstants.LAN_GAME_LOBBY_PORT), null).ConfigureAwait(false); lanGameLobby.Enable(); } @@ -332,80 +286,161 @@ private void DdColor_SelectedIndexChanged(object sender, EventArgs e) UserINISettings.Instance.SaveSettings(); } - public void Open() + public async ValueTask OpenAsync() { players.Clear(); lbPlayerList.Clear(); lbGameList.ClearGames(); + cancellationTokenSource?.Dispose(); Visible = true; Enabled = true; + cancellationTokenSource = new(); Logger.Log("Creating LAN socket."); - try + List lanIpV4Addresses; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - socket.EnableBroadcast = true; - socket.Bind(new IPEndPoint(IPAddress.Any, ProgramConstants.LAN_LOBBY_PORT)); - endPoint = new IPEndPoint(IPAddress.Broadcast, ProgramConstants.LAN_LOBBY_PORT); - initSuccess = true; + lanIpV4Addresses = NetworkHelper.GetWindowsLanUniCastIpAddresses() + .Where(q => q.Address.AddressFamily is AddressFamily.InterNetwork) + .ToList(); } - catch (SocketException ex) + else + { + lanIpV4Addresses = NetworkHelper.GetLanUniCastIpAddresses() + .Where(q => q.Address.AddressFamily is AddressFamily.InterNetwork) + .ToList(); + } + + if (!lanIpV4Addresses.Any()) { - Logger.Log("Creating LAN socket failed! Message: " + ex.Message); - lbChatMessages.AddMessage(new ChatMessage(Color.Red, - "Creating LAN socket failed! Message:".L10N("Client:Main:SocketFailure1") + " " + ex.Message)); - lbChatMessages.AddMessage(new ChatMessage(Color.Red, - "Please check your firewall settings.".L10N("Client:Main:SocketFailure2"))); - lbChatMessages.AddMessage(new ChatMessage(Color.Red, - "Also make sure that no other application is listening to traffic on UDP ports 1232 - 1234.".L10N("Client:Main:SocketFailure3"))); - initSuccess = false; + Logger.Log("No IPv4 address found for LAN."); + lbChatMessages.AddMessage(new ChatMessage(Color.Red, "No IPv4 address found for LAN".L10N("Client:Main:NoLANIPv4"))); + return; } - Logger.Log("Starting listener."); - new Thread(new ThreadStart(Listen)).Start(); + foreach (UnicastIPAddressInformation lanIpV4Address in lanIpV4Addresses) + { + var broadcastIpEndpoint = new IPEndPoint(NetworkHelper.GetIpV4BroadcastAddress(lanIpV4Address), ProgramConstants.LAN_LOBBY_PORT); + + try + { + var socket = new Socket(SocketType.Dgram, ProtocolType.Udp) + { + EnableBroadcast = true + }; + + sockets.Add((socket, broadcastIpEndpoint)); + socket.Bind(new IPEndPoint(lanIpV4Address.Address, ProgramConstants.LAN_LOBBY_PORT)); + + initSuccess = true; + + Logger.Log($"Created LAN broadcast socket {socket.LocalEndPoint} / {broadcastIpEndpoint}."); + } + catch (SocketException ex) + { + ProgramConstants.LogException(ex, "Creating LAN socket failed!"); + lbChatMessages.AddMessage(new ChatMessage(Color.Red, + string.Format( + CultureInfo.CurrentCulture, + $""" + {"Creating LAN socket failed! Message: {0}".L10N("Client:Main:SocketFailure1")} + {"Please check your firewall settings.".L10N("Client:Main:SocketFailure2")} + {"Also make sure that no other application is listening to traffic on UDP ports {1} - {2}.".L10N("Client:Main:SocketFailure3")} + """, + ex.Message, + ProgramConstants.LAN_LOBBY_PORT, + ProgramConstants.LAN_INGAME_PORT))); + } + } + + if (!initSuccess) + return; + + Logger.Log("Starting LAN listeners."); + + foreach ((Socket socket, IPEndPoint broadcastIpEndpoint) in sockets) + ListenAsync(socket, broadcastIpEndpoint, cancellationTokenSource.Token).HandleTask(); - SendAlive(); + await SendAliveAsync(cancellationTokenSource.Token).ConfigureAwait(false); } - private void SendMessage(string message) + private async ValueTask SendMessageAsync(string message, CancellationToken cancellationToken) { if (!initSuccess) return; - byte[] buffer; +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Task.Run(() => HandleNetworkMessage(message, loopBackIpEndPoint)).HandleTask(); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - buffer = encoding.GetBytes(message); + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); - socket.SendTo(buffer, endPoint); + buffer = buffer[..bytes]; + + foreach ((Socket socket, IPEndPoint broadcastEndpoint) in sockets) + { + try + { + await socket.SendToAsync(buffer, SocketFlags.None, broadcastEndpoint, cancellationToken).ConfigureAwait(false); +#if DEBUG +#if NETWORKTRACE + Logger.Log($"Sent LAN broadcast on {socket.LocalEndPoint} / {broadcastEndpoint}: {message}."); +#else + Logger.Log($"Sent LAN broadcast on {socket.LocalEndPoint} / {broadcastEndpoint}."); +#endif +#endif + } + catch (OperationCanceledException) + { + } + } } - private void Listen() + private async ValueTask ListenAsync(Socket socket, EndPoint broadcastEndpoint, CancellationToken cancellationToken) { try { - while (true) + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(4096); + + while (!cancellationToken.IsCancellationRequested) { - EndPoint ep = new IPEndPoint(IPAddress.Any, ProgramConstants.LAN_LOBBY_PORT); - byte[] buffer = new byte[4096]; - int receivedBytes = 0; - receivedBytes = socket.ReceiveFrom(buffer, ref ep); + Memory buffer = memoryOwner.Memory[..4096]; + SocketReceiveFromResult socketReceiveFromResult = + await socket.ReceiveFromAsync(buffer, SocketFlags.None, broadcastEndpoint, cancellationToken).ConfigureAwait(false); + var remoteIpEndPoint = (IPEndPoint)socketReceiveFromResult.RemoteEndPoint; - IPEndPoint iep = (IPEndPoint)ep; + if (sockets.Select(q => ((IPEndPoint)q.Socket.LocalEndPoint).Address).ToList().Contains(remoteIpEndPoint.Address)) + continue; - string data = encoding.GetString(buffer, 0, receivedBytes); + string data = encoding.GetString(buffer.Span[..socketReceiveFromResult.ReceivedBytes]); - if (data == string.Empty) + if (string.IsNullOrEmpty(data)) continue; - AddCallback(new Action(HandleNetworkMessage), data, iep); +#if DEBUG +#if NETWORKTRACE + Logger.Log($"Received LAN broadcast on {socket.LocalEndPoint} / {broadcastEndpoint}: {data}."); +#else + Logger.Log($"Received LAN broadcast on {socket.LocalEndPoint} / {broadcastEndpoint}."); +#endif +#endif + AddCallback(() => HandleNetworkMessage(data, remoteIpEndPoint)); } } + catch (OperationCanceledException) + { + } catch (Exception ex) { - Logger.Log("LAN socket listener: exception: " + ex.Message); + ProgramConstants.LogException(ex, "LAN socket listener exception."); } } @@ -418,15 +453,15 @@ private void HandleNetworkMessage(string data, IPEndPoint endPoint) string command = commandAndParams[0]; - string[] parameters = data.Substring(command.Length + 1).Split( - new char[] { ProgramConstants.LAN_DATA_SEPARATOR }, + string[] parameters = data[(command.Length + 1)..].Split( + new[] { ProgramConstants.LAN_DATA_SEPARATOR }, StringSplitOptions.RemoveEmptyEntries); LANLobbyUser user = players.Find(p => p.EndPoint.Equals(endPoint)); switch (command) { - case "ALIVE": + case LANCommands.ALIVE: if (parameters.Length < 2) return; @@ -448,7 +483,7 @@ private void HandleNetworkMessage(string data, IPEndPoint endPoint) user.TimeWithoutRefresh = TimeSpan.Zero; break; - case "CHAT": + case LANCommands.CHAT: if (user == null) return; @@ -464,7 +499,7 @@ private void HandleNetworkMessage(string data, IPEndPoint endPoint) chatColors[colorIndex].XNAColor, DateTime.Now, parameters[1])); break; - case "QUIT": + case LANCommands.QUIT: if (user == null) return; @@ -473,7 +508,7 @@ private void HandleNetworkMessage(string data, IPEndPoint endPoint) players.RemoveAt(index); lbPlayerList.Items.RemoveAt(index); break; - case "GAME": + case LANCommands.GAME: if (user == null) return; @@ -497,44 +532,46 @@ private void HandleNetworkMessage(string data, IPEndPoint endPoint) } } - private void SendAlive() + private async ValueTask SendAliveAsync(CancellationToken cancellationToken) { - StringBuilder sb = new StringBuilder("ALIVE "); + StringBuilder sb = new StringBuilder(LANCommands.ALIVE + " "); sb.Append(localGameIndex); sb.Append(ProgramConstants.LAN_DATA_SEPARATOR); sb.Append(ProgramConstants.PLAYERNAME); - SendMessage(sb.ToString()); + await SendMessageAsync(sb.ToString(), cancellationToken).ConfigureAwait(false); timeSinceAliveMessage = TimeSpan.Zero; } - private void TbChatInput_EnterPressed(object sender, EventArgs e) + private async ValueTask TbChatInput_EnterPressedAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(tbChatInput.Text)) return; string chatMessage = tbChatInput.Text.Replace((char)01, '?'); - StringBuilder sb = new StringBuilder("CHAT "); + StringBuilder sb = new StringBuilder(LANCommands.CHAT + " "); sb.Append(ddColor.SelectedIndex); sb.Append(ProgramConstants.LAN_DATA_SEPARATOR); sb.Append(chatMessage); - SendMessage(sb.ToString()); + await SendMessageAsync(sb.ToString(), cancellationToken).ConfigureAwait(false); tbChatInput.Text = string.Empty; } - private void LbGameList_DoubleLeftClick(object sender, EventArgs e) + private async ValueTask JoinGameAsync() { if (lbGameList.SelectedIndex < 0 || lbGameList.SelectedIndex >= lbGameList.Items.Count) return; HostedLANGame hg = (HostedLANGame)lbGameList.Items[lbGameList.SelectedIndex].Tag; - if (hg.Game.InternalName.ToUpper() != localGame.ToUpper()) + if (!hg.Game.InternalName.Equals(localGame, StringComparison.OrdinalIgnoreCase)) { lbChatMessages.AddMessage( - string.Format("The selected game is for {0}!".L10N("Client:Main:GameIsOfPurpose"), gameCollection.GetGameNameFromInternalName(hg.Game.InternalName))); + string.Format(CultureInfo.CurrentCulture, + "The selected game is for {0}!".L10N("Client:Main:GameIsOfPurpose"), + gameCollection.GetGameNameFromInternalName(hg.Game.InternalName))); return; } @@ -566,73 +603,82 @@ private void LbGameList_DoubleLeftClick(object sender, EventArgs e) // TODO Show warning } - lbChatMessages.AddMessage(string.Format("Attempting to join game {0} ...".L10N("Client:Main:AttemptJoin"), hg.RoomName)); + lbChatMessages.AddMessage( + string.Format(CultureInfo.CurrentCulture, "Attempting to join game {0} ...".L10N("Client:Main:AttemptJoin"), hg.RoomName)); try { - var client = new TcpClient(hg.EndPoint.Address.ToString(), ProgramConstants.LAN_GAME_LOBBY_PORT); + var client = new Socket(SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(new IPEndPoint(hg.EndPoint.Address, ProgramConstants.LAN_GAME_LOBBY_PORT), CancellationToken.None).ConfigureAwait(false); - byte[] buffer; + const int charSize = sizeof(char); if (hg.IsLoadedGame) { var spawnSGIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, ProgramConstants.SAVED_GAME_SPAWN_INI)); - int loadedGameId = spawnSGIni.GetIntValue("Settings", "GameID", -1); - lanGameLoadingLobby.SetUp(false, hg.EndPoint, client, loadedGameId); + await lanGameLoadingLobby.SetUpAsync(false, client, loadedGameId).ConfigureAwait(false); lanGameLoadingLobby.Enable(); - buffer = encoding.GetBytes("JOIN" + ProgramConstants.LAN_DATA_SEPARATOR + - ProgramConstants.PLAYERNAME + ProgramConstants.LAN_DATA_SEPARATOR + - loadedGameId + ProgramConstants.LAN_MESSAGE_SEPARATOR); - - client.GetStream().Write(buffer, 0, buffer.Length); - client.GetStream().Flush(); - - lanGameLoadingLobby.PostJoin(); + string message = FormattableString.Invariant($""" + {LANCommands.PLAYER_JOIN}{ProgramConstants.LAN_DATA_SEPARATOR}{ProgramConstants.PLAYERNAME}{ProgramConstants.LAN_DATA_SEPARATOR}{loadedGameId}{ProgramConstants.LAN_MESSAGE_SEPARATOR} + """); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + buffer = buffer[..bytes]; + + await client.SendAsync(buffer, SocketFlags.None, CancellationToken.None).ConfigureAwait(false); + await lanGameLoadingLobby.PostJoinAsync().ConfigureAwait(false); } else { - lanGameLobby.SetUp(false, hg.EndPoint, client); + await lanGameLobby.SetUpAsync(false, hg.EndPoint, client).ConfigureAwait(false); lanGameLobby.Enable(); - buffer = encoding.GetBytes("JOIN" + ProgramConstants.LAN_DATA_SEPARATOR + - ProgramConstants.PLAYERNAME + ProgramConstants.LAN_MESSAGE_SEPARATOR); + string message = LANCommands.PLAYER_JOIN + ProgramConstants.LAN_DATA_SEPARATOR + + ProgramConstants.PLAYERNAME + ProgramConstants.LAN_MESSAGE_SEPARATOR; + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + buffer = buffer[..bytes]; - client.GetStream().Write(buffer, 0, buffer.Length); - client.GetStream().Flush(); - - lanGameLobby.PostJoin(); + await client.SendAsync(buffer, SocketFlags.None, CancellationToken.None).ConfigureAwait(false); + await lanGameLobby.PostJoinAsync().ConfigureAwait(false); } } catch (Exception ex) { - lbChatMessages.AddMessage(null, - "Connecting to the game failed! Message:".L10N("Client:Main:ConnectGameFailed") + " " + ex.Message, Color.White); + ProgramConstants.LogException(ex, "Connecting to the game failed!"); + lbChatMessages.AddMessage(null, string.Format( + CultureInfo.CurrentCulture, + "Connecting to the game failed! Message: {0}".L10N("Client:Main:ConnectGameFailed"), + ex.Message), Color.White); } } - private void BtnMainMenu_LeftClick(object sender, EventArgs e) + private async ValueTask BtnMainMenu_LeftClickAsync() { Visible = false; Enabled = false; - SendMessage("QUIT"); - socket.Close(); - Exited?.Invoke(this, EventArgs.Empty); - } + await SendMessageAsync(LANCommands.PLAYER_QUIT_COMMAND, CancellationToken.None).ConfigureAwait(false); + cancellationTokenSource.Cancel(); - private void BtnJoinGame_LeftClick(object sender, EventArgs e) - { - LbGameList_DoubleLeftClick(this, EventArgs.Empty); + foreach ((Socket socket, _) in sockets) + socket.Close(); + + Exited?.Invoke(this, EventArgs.Empty); } - private void BtnNewGame_LeftClick(object sender, EventArgs e) + private async ValueTask BtnNewGame_LeftClickAsync() { if (!ClientConfiguration.Instance.DisableMultiplayerGameLoading) gameCreationWindow.Open(); else - GameCreationWindow_NewGame(sender, e); + await GameCreationWindow_NewGameAsync().ConfigureAwait(false); } public override void Update(GameTime gameTime) @@ -651,9 +697,9 @@ public override void Update(GameTime gameTime) timeSinceAliveMessage += gameTime.ElapsedGameTime; if (timeSinceAliveMessage > TimeSpan.FromSeconds(ALIVE_MESSAGE_INTERVAL)) - SendAlive(); + Task.Run(() => SendAliveAsync(cancellationTokenSource?.Token ?? default).HandleTask()).Wait(); base.Update(gameTime); } } -} +} \ No newline at end of file diff --git a/DXMainClient/DXMainClient.csproj b/DXMainClient/DXMainClient.csproj index 5eded4e66..62b11931c 100644 --- a/DXMainClient/DXMainClient.csproj +++ b/DXMainClient/DXMainClient.csproj @@ -6,11 +6,8 @@ CnCNet Main Client Library CnCNet CnCNet Client - Copyright © CnCNet, Rampastring 2011-2022 + Copyright © CnCNet, Rampastring 2011-2023 CnCNet - 2.8.0.0 - 2.8.0.0 - 2.8.0.0 DXMainClient DTAClient clienticon.ico @@ -31,23 +28,24 @@ - + - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + - + diff --git a/DXMainClient/Domain/DiscordHandler.cs b/DXMainClient/Domain/DiscordHandler.cs index b87e2bbfa..f275ba1e4 100644 --- a/DXMainClient/Domain/DiscordHandler.cs +++ b/DXMainClient/Domain/DiscordHandler.cs @@ -2,9 +2,7 @@ using ClientCore; using DiscordRPC; using DiscordRPC.Message; -using Microsoft.Xna.Framework; using Rampastring.Tools; -using Rampastring.XNAUI; using System.Text.RegularExpressions; namespace DTAClient.Domain @@ -12,7 +10,7 @@ namespace DTAClient.Domain /// /// A class for handling Discord integration. /// - public class DiscordHandler: IDisposable + public class DiscordHandler : IDisposable { private DiscordRpcClient client; @@ -54,8 +52,6 @@ public DiscordHandler() InitializeClient(); UpdatePresence(); Connect(); - - AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose(); } #region overrides @@ -313,4 +309,4 @@ public void Dispose() client.Dispose(); } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/FinalSunSettings.cs b/DXMainClient/Domain/FinalSunSettings.cs index 867ae1a8f..1cb66c552 100644 --- a/DXMainClient/Domain/FinalSunSettings.cs +++ b/DXMainClient/Domain/FinalSunSettings.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using Rampastring.Tools; using ClientCore; using ClientCore.PlatformShim; @@ -58,9 +59,9 @@ public static void WriteFinalSunIni() sw.WriteLine("DisableAutoLat=0"); sw.WriteLine("ShowBuildingCells=0"); } - catch + catch (Exception ex) { - Logger.Log("An exception occurred while checking the existence of FinalSun settings"); + ProgramConstants.LogException(ex, "An exception occurred while checking the existence of FinalSun settings."); } } } diff --git a/DXMainClient/Domain/MainClientConstants.cs b/DXMainClient/Domain/MainClientConstants.cs deleted file mode 100644 index 0197f113f..000000000 --- a/DXMainClient/Domain/MainClientConstants.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ClientCore; - -namespace DTAClient.Domain -{ - public static class MainClientConstants - { - public const string CNCNET_TUNNEL_LIST_URL = "http://cncnet.org/master-list"; - - public static string GAME_NAME_LONG = "CnCNet Client"; - public static string GAME_NAME_SHORT = "CnCNet"; - - public static string CREDITS_URL = "http://rampastring.cncnet.org/TS/Credits.txt"; - - public static string SUPPORT_URL_SHORT = "www.cncnet.org"; - - public static bool USE_ISOMETRIC_CELLS = true; - public static int TDRA_WAYPOINT_COEFFICIENT = 128; - public static int MAP_CELL_SIZE_X = 48; - public static int MAP_CELL_SIZE_Y = 24; - - public static OSVersion OSId = OSVersion.UNKNOWN; - - public static void Initialize() - { - var clientConfiguration = ClientConfiguration.Instance; - - OSId = clientConfiguration.GetOperatingSystemVersion(); - - GAME_NAME_SHORT = clientConfiguration.LocalGame; - GAME_NAME_LONG = clientConfiguration.LongGameName; - - SUPPORT_URL_SHORT = clientConfiguration.ShortSupportURL; - - CREDITS_URL = clientConfiguration.CreditsURL; - - USE_ISOMETRIC_CELLS = clientConfiguration.UseIsometricCells; - TDRA_WAYPOINT_COEFFICIENT = clientConfiguration.WaypointCoefficient; - MAP_CELL_SIZE_X = clientConfiguration.MapCellSizeX; - MAP_CELL_SIZE_Y = clientConfiguration.MapCellSizeY; - - if (string.IsNullOrEmpty(GAME_NAME_SHORT)) - throw new ClientConfigurationException("LocalGame is set to an empty value."); - - if (GAME_NAME_SHORT.Length > ProgramConstants.GAME_ID_MAX_LENGTH) - { - throw new ClientConfigurationException("LocalGame is set to a value that exceeds length limit of " + - ProgramConstants.GAME_ID_MAX_LENGTH + " characters."); - } - } - } -} diff --git a/DXMainClient/Domain/Multiplayer/AllianceHolder.cs b/DXMainClient/Domain/Multiplayer/AllianceHolder.cs index 3f7929695..6a97ff5c7 100644 --- a/DXMainClient/Domain/Multiplayer/AllianceHolder.cs +++ b/DXMainClient/Domain/Multiplayer/AllianceHolder.cs @@ -6,16 +6,15 @@ namespace DTAClient.Domain.Multiplayer /// /// A helper class for setting up alliances in spawn.ini. /// - public static class AllianceHolder + internal static class AllianceHolder { public static void WriteInfoToSpawnIni( List players, - List aiPlayers, + List aiPlayers, List multiCmbIndexes, List playerHouseInfos, List teamStartMappings, - IniFile spawnIni - ) + IniFile spawnIni) { List team1MultiMemberIds = new List(); List team2MultiMemberIds = new List(); @@ -58,7 +57,6 @@ IniFile spawnIni if (teamId <= 0) teamId = teamStartMappings?.Find(sa => sa.StartingWaypoint == phi.StartingWaypoint)?.TeamId ?? 0; - if (teamId > 0) { switch (teamId) diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetCommands.cs b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetCommands.cs new file mode 100644 index 000000000..76963670e --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetCommands.cs @@ -0,0 +1,49 @@ +#pragma warning disable SA1310 +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal static class CnCNetCommands +{ + public const string GAME_INVITE = "INVITE"; + public const string GAME_INVITATION_FAILED = "INVITATION_FAILED"; + public const string NOT_ALL_PLAYERS_PRESENT = "NPRSNT"; + public const string GET_READY = "GTRDY"; + public const string FILE_HASH = "FHSH"; + public const string INVALID_FILE_HASH = "IHSH"; + public const string TUNNEL_PING = "TNLPNG"; + public const string OPTIONS = "OP"; + public const string INVALID_SAVED_GAME_INDEX = "ISGI"; + public const string START_GAME = "START"; + public const string PLAYER_READY = "READY"; + public const string CHANGE_TUNNEL_SERVER = "CHTNL"; + public const string RETURN = "RETURN"; + public const string GET_READY_LOBBY = "GETREADY"; + public const string PLAYER_EXTRA_OPTIONS = "PEO"; + public const string MAP_SHARING_FAIL = "MAPFAIL"; + public const string MAP_SHARING_DOWNLOAD = "MAPOK"; + public const string MAP_SHARING_UPLOAD = "MAPREQ"; + public const string MAP_SHARING_DISABLED = "MAPSDISABLED"; + public const string CHEAT_DETECTED = "CD"; + public const string DICE_ROLL = "DR"; + public const string GAME_START_V3 = "STARTV3"; + public const string TUNNEL_CONNECTION_OK = "TNLOK"; + public const string TUNNEL_CONNECTION_FAIL = "TNLFAIL"; + public const string GAME_START_V2 = "START"; + public const string OPTIONS_REQUEST = "OR"; + public const string READY_REQUEST = "R"; + public const string PLAYER_OPTIONS = "PO"; + public const string GAME_OPTIONS = "GO"; + public const string AI_SPECTATORS = "AISPECS"; + public const string INSUFFICIENT_PLAYERS = "INSFSPLRS"; + public const string TOO_MANY_PLAYERS = "TMPLRS"; + public const string SHARED_COLORS = "CLRS"; + public const string SHARED_STARTING_LOCATIONS = "SLOC"; + public const string LOCK_GAME = "LCKGME"; + public const string NOT_VERIFIED = "NVRFY"; + public const string STILL_IN_GAME = "INGM"; + public const string CHEATER = "MM"; + public const string GAME = "GAME"; + public const string PLAYER_TUNNEL_PINGS = "TNLPINGS"; + public const string PLAYER_P2P_REQUEST = "P2PREQ"; + public const string PLAYER_P2P_PINGS = "P2PPINGS"; + public const string UPDATE = "UPDATE"; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetLobbyCommands.cs b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetLobbyCommands.cs new file mode 100644 index 000000000..5c075fc68 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetLobbyCommands.cs @@ -0,0 +1,23 @@ +#pragma warning disable SA1310 +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal static class CnCNetLobbyCommands +{ + public const string TUNNELINFO = "TUNNELINFO"; + public const string CHANGETUNNEL = "CHANGETUNNEL"; + public const string DOWNLOADMAP = "DOWNLOADMAP"; + public const string HIDEMAPS = "HIDEMAPS"; + public const string SHOWMAPS = "SHOWMAPS"; + public const string FRAMESENDRATE = "FRAMESENDRATE"; + public const string MAXAHEAD = "MAXAHEAD"; + public const string PROTOCOLVERSION = "PROTOCOLVERSION"; + public const string LOADMAP = "LOADMAP"; + public const string RANDOMSTARTS = "RANDOMSTARTS"; + public const string ROLL = "ROLL"; + public const string SAVEOPTIONS = "SAVEOPTIONS"; + public const string LOADOPTIONS = "LOADOPTIONS"; + public const string DYNAMICTUNNELS = "DYNAMICTUNNELS"; + public const string P2P = "P2P"; + public const string RECORD = "RECORD"; + public const string REPLAY = "REPLAY"; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetPlayerCountTask.cs b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetPlayerCountTask.cs index 0bf357b90..807bdc962 100644 --- a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetPlayerCountTask.cs +++ b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetPlayerCountTask.cs @@ -1,8 +1,9 @@ -using ClientCore; -using System; -using System.IO; -using System.Net; +using System; +using System.Globalization; using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; namespace DTAClient.Domain.Multiplayer.CnCNet { @@ -11,9 +12,8 @@ namespace DTAClient.Domain.Multiplayer.CnCNet /// public static class CnCNetPlayerCountTask { - public static int PlayerCount { get; private set; } - - private static int REFRESH_INTERVAL = 60000; // 1 minute + private const int REFRESH_INTERVAL = 60000; + private const int REFRESH_TIMEOUT = 10000; internal static event EventHandler CnCNetGameCountUpdated; @@ -22,49 +22,38 @@ public static class CnCNetPlayerCountTask public static void InitializeService(CancellationTokenSource cts) { cncnetLiveStatusIdentifier = ClientConfiguration.Instance.CnCNetLiveStatusIdentifier; - PlayerCount = GetCnCNetPlayerCount(); - CnCNetGameCountUpdated?.Invoke(null, new PlayerCountEventArgs(PlayerCount)); - ThreadPool.QueueUserWorkItem(new WaitCallback(RunService), cts); + RunServiceAsync(cts.Token).HandleTask(); } - private static void RunService(object tokenObj) + private static async ValueTask RunServiceAsync(CancellationToken cancellationToken) { - var waitHandle = ((CancellationTokenSource)tokenObj).Token.WaitHandle; - - while (true) + while (!cancellationToken.IsCancellationRequested) { - if (waitHandle.WaitOne(REFRESH_INTERVAL)) + try { - // Cancellation signaled - return; + using var timeoutCancellationTokenSource = new CancellationTokenSource(REFRESH_TIMEOUT); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken); + + CnCNetGameCountUpdated?.Invoke(null, new PlayerCountEventArgs(await GetCnCNetPlayerCountAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false))); + await Task.Delay(REFRESH_INTERVAL, cancellationToken).ConfigureAwait(false); } - else + catch (OperationCanceledException) { - CnCNetGameCountUpdated?.Invoke(null, new PlayerCountEventArgs(GetCnCNetPlayerCount())); } } } - private static int GetCnCNetPlayerCount() + private static async ValueTask GetCnCNetPlayerCountAsync(CancellationToken cancellationToken) { try { - WebClient client = new WebClient(); - - Stream data = client.OpenRead("http://api.cncnet.org/status"); - - string info = string.Empty; - - using (StreamReader reader = new StreamReader(data)) - { - info = reader.ReadToEnd(); - } + string info = await Constants.CnCNetHttpClient.GetStringAsync($"{Uri.UriSchemeHttps}://api.cncnet.org/status", cancellationToken).ConfigureAwait(false); - info = info.Replace("{", String.Empty); - info = info.Replace("}", String.Empty); - info = info.Replace("\"", String.Empty); - string[] values = info.Split(new char[] { ',' }); + info = info.Replace("{", string.Empty); + info = info.Replace("}", string.Empty); + info = info.Replace("\"", string.Empty); + string[] values = info.Split(new[] { ',' }); int numGames = -1; @@ -72,21 +61,22 @@ private static int GetCnCNetPlayerCount() { if (value.Contains(cncnetLiveStatusIdentifier)) { - numGames = Convert.ToInt32(value.Substring(cncnetLiveStatusIdentifier.Length + 1)); + numGames = Convert.ToInt32(value[(cncnetLiveStatusIdentifier.Length + 1)..], CultureInfo.InvariantCulture); return numGames; } } return numGames; } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); return -1; } } } - internal class PlayerCountEventArgs : EventArgs + internal sealed class PlayerCountEventArgs : EventArgs { public PlayerCountEventArgs(int playerCount) { @@ -95,4 +85,4 @@ public PlayerCountEventArgs(int playerCount) public int PlayerCount { get; set; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetTunnel.cs b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetTunnel.cs index edb45d85e..cc1d9c219 100644 --- a/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetTunnel.cs +++ b/DXMainClient/Domain/Multiplayer/CnCNet/CnCNetTunnel.cs @@ -1,145 +1,238 @@ -using Rampastring.Tools; -using System; +using System; +using System.Buffers; using System.Collections.Generic; using System.Globalization; using System.Net; -using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using Rampastring.Tools; namespace DTAClient.Domain.Multiplayer.CnCNet { /// /// A CnCNet tunnel server. /// - public class CnCNetTunnel + internal sealed class CnCNetTunnel { - private const int REQUEST_TIMEOUT = 10000; // In milliseconds + private const int PING_PACKET_SEND_SIZE = 50; private const int PING_TIMEOUT = 1000; + private const int HASH_LENGTH = 10; - public CnCNetTunnel() { } + private string ipAddress; + private string hash; /// - /// Parses a formatted string that contains the tunnel server's + /// Parses a formatted string that contains the tunnel server's /// information into a CnCNetTunnel instance. /// /// The string that contains the tunnel server's information. /// A CnCNetTunnel instance parsed from the given string. - public static CnCNetTunnel Parse(string str) + public static CnCNetTunnel Parse(string str, bool hasIPv6Internet, bool hasIPv4Internet) { - // For the format, check http://cncnet.org/master-list - + // For the format, check https://cncnet.org/api/v1/master-list try { var tunnel = new CnCNetTunnel(); string[] parts = str.Split(';'); + string addressAndPort = parts[0]; + string secondaryAddress = parts.Length > 12 ? parts[12] : null; + int version = int.Parse(parts[10], CultureInfo.InvariantCulture); + string primaryAddress = addressAndPort[..addressAndPort.LastIndexOf(':')]; + var primaryIpAddress = IPAddress.Parse(primaryAddress); + IPAddress secondaryIpAddress = string.IsNullOrWhiteSpace(secondaryAddress) ? null : IPAddress.Parse(secondaryAddress); + + if (hasIPv6Internet && primaryIpAddress.AddressFamily is AddressFamily.InterNetworkV6) + { + tunnel.Address = primaryIpAddress.ToString(); + } + else if (hasIPv6Internet && secondaryIpAddress?.AddressFamily is AddressFamily.InterNetworkV6) + { + tunnel.Address = secondaryIpAddress.ToString(); + } + else if (hasIPv4Internet && primaryIpAddress.AddressFamily is AddressFamily.InterNetwork) + { + tunnel.Address = primaryIpAddress.ToString(); + } + else if (hasIPv4Internet && secondaryIpAddress?.AddressFamily is AddressFamily.InterNetwork) + { + tunnel.Address = secondaryIpAddress.ToString(); + } + else + { + Logger.Log($"No supported IP address/connection found ({nameof(NetworkHelper.HasIPv6Internet)}={hasIPv6Internet}, " + + $"{nameof(NetworkHelper.HasIPv4Internet)}={hasIPv4Internet}) for {primaryIpAddress} - {secondaryIpAddress}."); - string address = parts[0]; - string[] detailedAddress = address.Split(new char[] { ':' }); - - tunnel.Address = detailedAddress[0]; - tunnel.Port = int.Parse(detailedAddress[1]); + return null; + } + + tunnel.IPAddresses = new List { primaryIpAddress }; + + if (secondaryIpAddress is not null) + tunnel.IPAddresses.Add(secondaryIpAddress); + + tunnel.Port = int.Parse(addressAndPort[(addressAndPort.LastIndexOf(':') + 1)..], CultureInfo.InvariantCulture); tunnel.Country = parts[1]; tunnel.CountryCode = parts[2]; tunnel.Name = parts[3]; tunnel.RequiresPassword = parts[4] != "0"; - tunnel.Clients = int.Parse(parts[5]); - tunnel.MaxClients = int.Parse(parts[6]); - int status = int.Parse(parts[7]); + tunnel.Clients = int.Parse(parts[5], CultureInfo.InvariantCulture); + tunnel.MaxClients = int.Parse(parts[6], CultureInfo.InvariantCulture); + + int status = int.Parse(parts[7], CultureInfo.InvariantCulture); + tunnel.Official = status == 2; + if (!tunnel.Official) tunnel.Recommended = status == 1; - CultureInfo cultureInfo = CultureInfo.InvariantCulture; - - tunnel.Latitude = double.Parse(parts[8], cultureInfo); - tunnel.Longitude = double.Parse(parts[9], cultureInfo); - tunnel.Version = int.Parse(parts[10]); - tunnel.Distance = double.Parse(parts[11], cultureInfo); + tunnel.Latitude = double.Parse(parts[8], CultureInfo.InvariantCulture); + tunnel.Longitude = double.Parse(parts[9], CultureInfo.InvariantCulture); + tunnel.Version = version; + tunnel.Distance = double.Parse(parts[11], CultureInfo.InvariantCulture); return tunnel; } - catch (Exception ex) + catch (Exception ex) when (ex is FormatException or OverflowException or IndexOutOfRangeException) { - if (ex is FormatException || ex is OverflowException || ex is IndexOutOfRangeException) - { - Logger.Log("Parsing tunnel information failed: " + ex.Message + Environment.NewLine + "Parsed string: " + str); - return null; - } + ProgramConstants.LogException(ex, "Parsing tunnel information failed. Parsed string: " + str); + return null; + } + } + + public string Address + { + get => ipAddress; + private set + { + ipAddress = value; - throw; + if (IPAddress.TryParse(ipAddress, out IPAddress address)) + IPAddress = address; } } - public string Address { get; private set; } + public IPAddress IPAddress { get; private set; } + + public List IPAddresses { get; private set; } + public int Port { get; private set; } + public string Country { get; private set; } + public string CountryCode { get; private set; } + public string Name { get; private set; } + public bool RequiresPassword { get; private set; } + public int Clients { get; private set; } + public int MaxClients { get; private set; } + public bool Official { get; private set; } + public bool Recommended { get; private set; } + public double Latitude { get; private set; } + public double Longitude { get; private set; } + public int Version { get; private set; } + public double Distance { get; private set; } - public int PingInMs { get; set; } = -1; + + public int PingInMs { get; private set; } = -1; + + public string Hash + { + get + { + return hash ??= Utilities.CalculateSHA1ForString(FormattableString.Invariant($"{Version}{CountryCode}{Name}{Official}{Recommended}"))[..HASH_LENGTH]; + } + } /// /// Gets a list of player ports to use from a specific tunnel server. /// /// A list of player ports to use. - public List GetPlayerPortInfo(int playerCount) + public async ValueTask> GetPlayerPortInfoAsync(int playerCount) { + if (Version != Constants.TUNNEL_VERSION_2) + throw new InvalidOperationException($"{nameof(GetPlayerPortInfoAsync)} only works with version {Constants.TUNNEL_VERSION_2} tunnels."); + try { - Logger.Log($"Contacting tunnel at {Address}:{Port}"); + string addressString = $"{Uri.UriSchemeHttp}://{Address}:{Port}/request?clients={playerCount}"; - string addressString = $"http://{Address}:{Port}/request?clients={playerCount}"; - Logger.Log($"Downloading from {addressString}"); - - using (var client = new ExtendedWebClient(REQUEST_TIMEOUT)) - { - string data = client.DownloadString(addressString); + Logger.Log($"Contacting tunnel at {addressString}"); - data = data.Replace("[", String.Empty); - data = data.Replace("]", String.Empty); + string data = await Constants.CnCNetHttpClient.GetStringAsync(addressString).ConfigureAwait(false); - string[] portIDs = data.Split(','); - List playerPorts = new List(); + data = data.Replace("[", string.Empty); + data = data.Replace("]", string.Empty); - foreach (string _port in portIDs) - { - playerPorts.Add(Convert.ToInt32(_port)); - Logger.Log($"Added port {_port}"); - } + string[] portIDs = data.Split(','); + var playerPorts = new List(); - return playerPorts; + foreach (string port in portIDs) + { + playerPorts.Add(Convert.ToInt32(port, CultureInfo.InvariantCulture)); + Logger.Log($"Added port {port}"); } + + return playerPorts; } catch (Exception ex) { - Logger.Log("Unable to connect to the specified tunnel server. Returned error message: " + ex.Message); + ProgramConstants.LogException(ex, "Unable to connect to the specified tunnel server."); } return new List(); } - public void UpdatePing() + public async ValueTask UpdatePingAsync(CancellationToken cancellationToken) { - using (Ping p = new Ping()) + using var socket = new Socket(SocketType.Dgram, ProtocolType.Udp); + + try { - try - { - PingReply reply = p.Send(IPAddress.Parse(Address), PING_TIMEOUT); - if (reply.Status == IPStatus.Success) - PingInMs = Convert.ToInt32(reply.RoundtripTime); - } - catch (PingException ex) - { - Logger.Log($"Caught an exception when pinging {Name} tunnel server: {ex.Message}"); - } + EndPoint ep = new IPEndPoint(IPAddress, Port); + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(PING_PACKET_SEND_SIZE); + Memory buffer = memoryOwner.Memory[..PING_PACKET_SEND_SIZE]; + + buffer.Span.Clear(); + + long ticks = DateTime.Now.Ticks; + using var sendTimeoutCancellationTokenSource = new CancellationTokenSource(PING_TIMEOUT); + using var sendLinkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(sendTimeoutCancellationTokenSource.Token, cancellationToken); + + await socket.SendToAsync(buffer, SocketFlags.None, ep, sendLinkedCancellationTokenSource.Token).ConfigureAwait(false); + + using var receiveTimeoutCancellationTokenSource = new CancellationTokenSource(PING_TIMEOUT); + using var receiveLinkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(receiveTimeoutCancellationTokenSource.Token, cancellationToken); + + await socket.ReceiveFromAsync(buffer, SocketFlags.None, ep, receiveLinkedCancellationTokenSource.Token).ConfigureAwait(false); + + ticks = DateTime.Now.Ticks - ticks; + PingInMs = new TimeSpan(ticks).Milliseconds; + + return; } + catch (SocketException ex) + { + ProgramConstants.LogException(ex, $"Failed to ping tunnel {Name} ({Address}:{Port})."); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + Logger.Log($"Failed to ping tunnel (time-out) {Name} ({Address}:{Port})."); + } + catch (OperationCanceledException) + { + } + + PingInMs = -1; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/Constants.cs b/DXMainClient/Domain/Multiplayer/CnCNet/Constants.cs new file mode 100644 index 000000000..28b90ea0e --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/Constants.cs @@ -0,0 +1,24 @@ +using System; +using System.Net; +using System.Net.Http; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal static class Constants +{ + public static HttpClient CnCNetHttpClient + => new( + new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + AutomaticDecompression = DecompressionMethods.All + }, + true) + { + Timeout = TimeSpan.FromSeconds(10), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher + }; + + internal const int TUNNEL_VERSION_2 = 2; + internal const int TUNNEL_VERSION_3 = 3; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/DataReceivedEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/DataReceivedEventArgs.cs new file mode 100644 index 000000000..5d49afaf3 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/DataReceivedEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal sealed class DataReceivedEventArgs : EventArgs +{ + public DataReceivedEventArgs(uint playerId, Memory gameData) + { + PlayerId = playerId; + GameData = gameData; + } + + public DateTimeOffset Timestamp { get; } = DateTimeOffset.Now; + + public uint PlayerId { get; } + + public Memory GameData { get; } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/ExtendedWebClient.cs b/DXMainClient/Domain/Multiplayer/CnCNet/ExtendedWebClient.cs deleted file mode 100644 index b7afe0f52..000000000 --- a/DXMainClient/Domain/Multiplayer/CnCNet/ExtendedWebClient.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Net; - -namespace DTAClient.Domain.Multiplayer.CnCNet -{ - /// - /// A web client that supports customizing the timeout of the request. - /// - class ExtendedWebClient : WebClient - { - public ExtendedWebClient(int timeout) - { - this.timeout = timeout; - } - - private int timeout; - - protected override WebRequest GetWebRequest(Uri address) - { - WebRequest webRequest = base.GetWebRequest(address); - webRequest.Timeout = timeout; - return webRequest; - } - } -} diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/GameDataException.cs b/DXMainClient/Domain/Multiplayer/CnCNet/GameDataException.cs new file mode 100644 index 000000000..2da0d51c2 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/GameDataException.cs @@ -0,0 +1,7 @@ +using System; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal sealed class GameDataException : Exception +{ +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/HostedCnCNetGame.cs b/DXMainClient/Domain/Multiplayer/CnCNet/HostedCnCNetGame.cs index f97aff68d..c79c7361f 100644 --- a/DXMainClient/Domain/Multiplayer/CnCNet/HostedCnCNetGame.cs +++ b/DXMainClient/Domain/Multiplayer/CnCNet/HostedCnCNetGame.cs @@ -2,14 +2,10 @@ namespace DTAClient.Domain.Multiplayer.CnCNet { - public class HostedCnCNetGame : GenericHostedGame + internal sealed class HostedCnCNetGame : GenericHostedGame { - public HostedCnCNetGame() { } - - public HostedCnCNetGame(string channelName, string revision, string gamever, int maxPlayers, - string roomName, bool passworded, - bool tunneled, - string[] players, string adminName, string mapName, string gameMode) + public HostedCnCNetGame(string channelName, string revision, string gamever, int maxPlayers, string roomName, + bool passworded, bool tunneled, string[] players, string adminName, string mapName, string gameMode) { ChannelName = channelName; Revision = revision; @@ -31,11 +27,11 @@ public HostedCnCNetGame(string channelName, string revision, string gamever, int public string MatchID { get; set; } public CnCNetTunnel TunnelServer { get; set; } - public override int Ping => TunnelServer.PingInMs; + public override int Ping => TunnelServer?.PingInMs ?? 0; public override bool Equals(GenericHostedGame other) => other is HostedCnCNetGame hostedCnCNetGame ? string.Equals(hostedCnCNetGame.ChannelName, ChannelName, StringComparison.InvariantCultureIgnoreCase) : base.Equals(other); } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/IRCChannelModes.cs b/DXMainClient/Domain/Multiplayer/CnCNet/IRCChannelModes.cs new file mode 100644 index 000000000..b11fa6b0a --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/IRCChannelModes.cs @@ -0,0 +1,14 @@ +#pragma warning disable SA1310 +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal static class IRCChannelModes +{ + public const char BAN = 'b'; + public const char INVITE_ONLY = 'i'; + public const char CHANNEL_KEY = 'k'; + public const char CHANNEL_LIMIT = 'l'; + public const char NO_EXTERNAL_MESSAGES = 'n'; + public const char NO_NICKNAME_CHANGE = 'N'; + public const char SECRET_CHANNEL = 's'; + public static string DEFAULT = $"{CHANNEL_KEY}{CHANNEL_LIMIT}{NO_EXTERNAL_MESSAGES}{NO_NICKNAME_CHANGE}{SECRET_CHANNEL}"; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/IRCCommands.cs b/DXMainClient/Domain/Multiplayer/CnCNet/IRCCommands.cs new file mode 100644 index 000000000..2f42e7ce3 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/IRCCommands.cs @@ -0,0 +1,23 @@ +#pragma warning disable SA1310 +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal static class IRCCommands +{ + public const string JOIN = "JOIN"; + public const string QUIT = "QUIT"; + public const string NOTICE = "NOTICE"; + public const string PART = "PART"; + public const string PRIVMSG = "PRIVMSG"; + public const string MODE = "MODE"; + public const string KICK = "KICK"; + public const string ERROR = "ERROR"; + public const string PING = "PING"; + public const string PONG = "PONG"; + public const string TOPIC = "TOPIC"; + public const string NICK = "NICK"; + public const string PRIVMSG_ACTION = "ACTION"; + public const string PING_LAG = "PING LAG"; + public const string AWAY = "AWAY"; + public const string WHOIS = "WHOIS"; + public const string USER = "USER"; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs b/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs index 870dee82d..168b81561 100644 --- a/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs +++ b/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; -using System.Text; -using System.IO; -using System.Net; using System.Collections.Specialized; -using System.Globalization; -using System.Threading; -using Rampastring.Tools; -using ClientCore; +using System.IO; using System.IO.Compression; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; +using Rampastring.Tools; namespace DTAClient.Domain.Multiplayer.CnCNet { @@ -30,13 +30,13 @@ public static class MapSharer public static event EventHandler MapDownloadStarted; - private volatile static List MapDownloadQueue = new List(); - private volatile static List MapUploadQueue = new List(); - private volatile static List UploadedMaps = new List(); + private volatile static List MapDownloadQueue = new(); + private volatile static List MapUploadQueue = new(); + private volatile static List UploadedMaps = new(); - private static readonly object locker = new object(); + private static readonly object locker = new(); - private const string MAPDB_URL = "http://mapdb.cncnet.org/upload"; + private const string MAPDB_URL = "https://mapdb.cncnet.org/"; /// /// Adds a map into the CnCNet map upload queue. @@ -56,30 +56,17 @@ public static void UploadMap(Map map, string myGame) MapUploadQueue.Add(map); if (MapUploadQueue.Count == 1) - { - ParameterizedThreadStart pts = new ParameterizedThreadStart(Upload); - Thread thread = new Thread(pts); - object[] mapAndGame = new object[2]; - mapAndGame[0] = map; - mapAndGame[1] = myGame.ToLower(); - thread.Start(mapAndGame); - } + UploadAsync(map, myGame.ToLower()).HandleTask(); } } - private static void Upload(object mapAndGame) + private static async ValueTask UploadAsync(Map map, string myGameId) { - object[] mapGameArray = (object[])mapAndGame; - - Map map = (Map)mapGameArray[0]; - string myGameId = (string)mapGameArray[1]; - MapUploadStarted?.Invoke(null, new MapEventArgs(map)); Logger.Log("MapSharer: Starting upload of " + map.BaseFilePath); - bool success = false; - string message = MapUpload(MAPDB_URL, map, myGameId, out success); + (string message, bool success) = await MapUploadAsync(map, myGameId).ConfigureAwait(false); if (success) { @@ -90,7 +77,7 @@ private static void Upload(object mapAndGame) UploadedMaps.Add(map.SHA1); } - Logger.Log("MapSharer: Uploading map " + map.BaseFilePath + " completed succesfully."); + Logger.Log("MapSharer: Uploading map " + map.BaseFilePath + " completed successfully."); } else { @@ -107,179 +94,82 @@ private static void Upload(object mapAndGame) { Map nextMap = MapUploadQueue[0]; - object[] array = new object[2]; - array[0] = nextMap; - array[1] = myGameId; - Logger.Log("MapSharer: There are additional maps in the queue."); - Upload(array); + UploadAsync(nextMap, myGameId).HandleTask(); } } } - private static string MapUpload(string _URL, Map map, string gameName, out bool success) + private static async ValueTask<(string Message, bool Success)> MapUploadAsync(Map map, string gameName) { - ServicePointManager.Expect100Continue = false; - - FileInfo zipFile = SafePath.GetFile(ProgramConstants.GamePath, "Maps", "Custom", FormattableString.Invariant($"{map.SHA1}.zip")); - - if (zipFile.Exists) zipFile.Delete(); - - string mapFileName = map.SHA1 + MapLoader.MAP_FILE_EXTENSION; - - File.Copy(SafePath.CombineFilePath(map.CompleteFilePath), SafePath.CombineFilePath(ProgramConstants.GamePath, mapFileName)); - - CreateZipFile(mapFileName, zipFile.FullName); + using MemoryStream zipStream = CreateZipFile(map.CompleteFilePath); try { - SafePath.DeleteFileIfExists(ProgramConstants.GamePath, mapFileName); - } - catch { } - - // Upload the file to the URI. - // The 'UploadFile(uriString,fileName)' method implicitly uses HTTP POST method. - - try - { - using (FileStream stream = zipFile.Open(FileMode.Open)) - { - List files = new List(); - //{ - // new FileToUpload - // { - // Name = "file", - // Filename = Path.GetFileName(zipFile), - // ContentType = "mapZip", - // Stream = stream - // }; - //}; - - FileToUpload file = new FileToUpload() + var files = new List { - Name = "file", - Filename = zipFile.Name, - ContentType = "mapZip", - Stream = stream + new("file", FormattableString.Invariant($"{map.SHA1}.zip"), "mapZip", zipStream) }; - - files.Add(file); - - NameValueCollection values = new NameValueCollection - { - { "game", gameName.ToLower() }, - }; - - byte[] responseArray = UploadFiles(_URL, files, values); - string response = Encoding.UTF8.GetString(responseArray); - - if (!response.Contains("Upload succeeded!")) + var values = new NameValueCollection { - success = false; - return response; - } - Logger.Log("MapSharer: Upload response: " + response); + { "game", gameName.ToLower() } + }; + string response = await UploadFilesAsync(files, values).ConfigureAwait(false); - //MessageBox.Show((response)); + if (!response.Contains("Upload succeeded!")) + return (response, false); - success = true; - return String.Empty; - } + Logger.Log("MapSharer: Upload response: " + response); + + return (string.Empty, true); } catch (Exception ex) { - success = false; - return ex.Message; + ProgramConstants.LogException(ex); + return (ex.Message, false); } } - private static void CopyStream(Stream input, Stream output) + private static async ValueTask UploadFilesAsync(List files, NameValueCollection values) { - byte[] buffer = new byte[32768]; - int read; - while ((read = input.Read(buffer, 0, buffer.Length)) > 0) - { - output.Write(buffer, 0, read); - } - } + using var multipartFormDataContent = new MultipartFormDataContent(); - private static byte[] UploadFiles(string address, List files, NameValueCollection values) - { - WebRequest request = WebRequest.Create(address); - request.Method = "POST"; - string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x", NumberFormatInfo.InvariantInfo); - request.ContentType = "multipart/form-data; boundary=" + boundary; - boundary = "--" + boundary; - - using (Stream requestStream = request.GetRequestStream()) + // Write the values + foreach (string name in values.Keys) { - // Write the values - foreach (string name in values.Keys) - { - byte[] buffer = Encoding.ASCII.GetBytes(boundary + Environment.NewLine); - requestStream.Write(buffer, 0, buffer.Length); - - buffer = Encoding.ASCII.GetBytes(string.Format("Content-Disposition: form-data; name=\"{0}\"{1}{1}", name, Environment.NewLine)); - requestStream.Write(buffer, 0, buffer.Length); - - buffer = Encoding.UTF8.GetBytes(values[name] + Environment.NewLine); - requestStream.Write(buffer, 0, buffer.Length); - } - - // Write the files - foreach (FileToUpload file in files) - { - var buffer = Encoding.ASCII.GetBytes(boundary + Environment.NewLine); - requestStream.Write(buffer, 0, buffer.Length); - - buffer = Encoding.UTF8.GetBytes(string.Format("Content-Disposition: form-data; name=\"{0}\"; filename=\"{1}\"{2}", file.Name, file.Filename, Environment.NewLine)); - requestStream.Write(buffer, 0, buffer.Length); - - buffer = Encoding.ASCII.GetBytes(string.Format("Content-Type: {0}{1}{1}", file.ContentType, Environment.NewLine)); - requestStream.Write(buffer, 0, buffer.Length); - - CopyStream(file.Stream, requestStream); - - buffer = Encoding.ASCII.GetBytes(Environment.NewLine); - requestStream.Write(buffer, 0, buffer.Length); - } - - byte[] boundaryBuffer = Encoding.ASCII.GetBytes(boundary + "--"); - requestStream.Write(boundaryBuffer, 0, boundaryBuffer.Length); + multipartFormDataContent.Add(new StringContent(values[name]), name); } - using (WebResponse response = request.GetResponse()) + // Write the files + foreach (FileToUpload file in files) { - using (Stream responseStream = response.GetResponseStream()) + var streamContent = new StreamContent(file.Stream) { - using (MemoryStream stream = new MemoryStream()) - { + Headers = { ContentType = new MediaTypeHeaderValue("application/" + file.ContentType) } + }; + multipartFormDataContent.Add(streamContent, file.Name, file.Filename); + } - CopyStream(responseStream, stream); + HttpResponseMessage httpResponseMessage = await Constants.CnCNetHttpClient.PostAsync($"{MAPDB_URL}upload", multipartFormDataContent).ConfigureAwait(false); - return stream.ToArray(); - } - } - } + return await httpResponseMessage.EnsureSuccessStatusCode().Content.ReadAsStringAsync().ConfigureAwait(false); } - private static void CreateZipFile(string file, string zipName) + private static MemoryStream CreateZipFile(string file) { - using var zipFileStream = new FileStream(zipName, FileMode.CreateNew, FileAccess.Write); - using var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create); + var zipStream = new MemoryStream(1024); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, true); archive.CreateEntryFromFile(SafePath.CombineFilePath(ProgramConstants.GamePath, file), file); + + return zipStream; } - private static string ExtractZipFile(string zipFile, string destDir) + private static void ExtractZipFile(Stream stream, string file) { - using ZipArchive zipArchive = ZipFile.OpenRead(zipFile); - - // here, we extract every entry, but we could extract conditionally - // based on entry name, size, date, checkbox status, etc. - zipArchive.ExtractToDirectory(destDir); + using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); - return zipArchive.Entries.FirstOrDefault()?.Name; + zipArchive.Entries.FirstOrDefault().ExtractToFile(file, true); } public static void DownloadMap(string sha1, string myGame, string mapName) @@ -295,30 +185,14 @@ public static void DownloadMap(string sha1, string myGame, string mapName) MapDownloadQueue.Add(sha1); if (MapDownloadQueue.Count == 1) - { - object[] details = new object[3]; - details[0] = sha1; - details[1] = myGame.ToLower(); - details[2] = mapName; - - ParameterizedThreadStart pts = new ParameterizedThreadStart(Download); - Thread thread = new Thread(pts); - thread.Start(details); - } + DownloadAsync(sha1, myGame.ToLower(), mapName).HandleTask(); } } - private static void Download(object details) + private static async ValueTask DownloadAsync(string sha1, string myGameId, string mapName) { - object[] sha1AndGame = (object[])details; - string sha1 = (string)sha1AndGame[0]; - string myGameId = (string)sha1AndGame[1]; - string mapName = (string)sha1AndGame[2]; - Logger.Log("MapSharer: Preparing to download map " + sha1 + " with name: " + mapName); - bool success; - try { Logger.Log("MapSharer: MapDownloadStarted"); @@ -326,10 +200,10 @@ private static void Download(object details) } catch (Exception ex) { - Logger.Log("MapSharer: ERROR " + ex.Message); + ProgramConstants.LogException(ex, "MapSharer ERROR"); } - string mapPath = DownloadMain(sha1, myGameId, mapName, out success); + (string error, bool success) = await DownloadMainAsync(sha1, myGameId, mapName).ConfigureAwait(false); lock (locker) { @@ -340,124 +214,48 @@ private static void Download(object details) } else { - Logger.Log("MapSharer: Download of map " + sha1 + "failed! Reason: " + mapPath); + Logger.Log("MapSharer: Download of map " + sha1 + "failed! Reason: " + error); MapDownloadFailed?.Invoke(null, new SHA1EventArgs(sha1, mapName)); } MapDownloadQueue.Remove(sha1); - if (MapDownloadQueue.Count > 0) + if (MapDownloadQueue.Any()) { Logger.Log("MapSharer: Continuing custom map downloads."); - - object[] array = new object[3]; - array[0] = MapDownloadQueue[0]; - array[1] = myGameId; - array[2] = mapName; - - Download(array); + DownloadAsync(MapDownloadQueue[0], myGameId, mapName).HandleTask(); } } } public static string GetMapFileName(string sha1, string mapName) - => mapName + "_" + sha1; + => FormattableString.Invariant($"{mapName}_{sha1}"); - private static string DownloadMain(string sha1, string myGame, string mapName, out bool success) + private static async ValueTask<(string Error, bool Success)> DownloadMainAsync(string sha1, string myGame, string mapName) { string customMapsDirectory = SafePath.CombineDirectoryPath(ProgramConstants.GamePath, "Maps", "Custom"); - string mapFileName = GetMapFileName(sha1, mapName); + string newFile = SafePath.CombineFilePath(customMapsDirectory, FormattableString.Invariant($"{mapFileName}.map")); + Stream stream; - FileInfo destinationFile = SafePath.GetFile(customMapsDirectory, FormattableString.Invariant($"{mapFileName}.zip")); - - // This string is up here so we can check that there isn't already a .map file for this download. - // This prevents the client from crashing when trying to rename the unzipped file to a duplicate filename. - FileInfo newFile = SafePath.GetFile(customMapsDirectory, FormattableString.Invariant($"{mapFileName}{MapLoader.MAP_FILE_EXTENSION}")); - - destinationFile.Delete(); - newFile.Delete(); - - using (TWebClient webClient = new TWebClient()) + try { - webClient.Proxy = null; - - try - { - Logger.Log("MapSharer: Downloading URL: " + "http://mapdb.cncnet.org/" + myGame + "/" + sha1 + ".zip"); - webClient.DownloadFile("http://mapdb.cncnet.org/" + myGame + "/" + sha1 + ".zip", destinationFile.FullName); - } - catch (Exception ex) - { - /* if (ex.Message.Contains("404")) - { - string messageToSend = "NOTICE " + ChannelName + " " + CTCPChar1 + CTCPChar2 + "READY 1" + CTCPChar2; - CnCNetData.ConnectionBridge.SendMessage(messageToSend); - } - else - { - //GlobalVars.WriteLogfile(ex.StackTrace.ToString(), DateTime.Now.ToString("hh:mm:ss") + " DownloadMap: " + ex.Message + _DestFile); - MessageBox.Show("Download failed:" + _DestFile); - }*/ - success = false; - return ex.Message; - } + string address = FormattableString.Invariant($"{MAPDB_URL}{myGame}/{sha1}.zip"); + Logger.Log($"MapSharer: Downloading URL: {MAPDB_URL}{address})"); + stream = await Constants.CnCNetHttpClient.GetStreamAsync(address).ConfigureAwait(false); } - - destinationFile.Refresh(); - - if (!destinationFile.Exists) + catch (Exception ex) { - success = false; - return null; - } + ProgramConstants.LogException(ex); - string extractedFile = ExtractZipFile(destinationFile.FullName, customMapsDirectory); - - if (String.IsNullOrEmpty(extractedFile)) - { - success = false; - return null; + return (ex.Message, false); } - // We can safely assume that there will not be a duplicate file due to deleting it - // earlier if one already existed. - File.Move(SafePath.CombineFilePath(customMapsDirectory, extractedFile), newFile.FullName); - - destinationFile.Delete(); + ExtractZipFile(stream, newFile); - success = true; - return extractedFile; + return (null, true); } - class FileToUpload - { - public FileToUpload() - { - ContentType = "application/octet-stream"; - } - - public string Name { get; set; } - public string Filename { get; set; } - public string ContentType { get; set; } - public Stream Stream { get; set; } - } - - class TWebClient : WebClient - { - private int Timeout = 10000; - - public TWebClient() - { - this.Proxy = null; - } - - protected override WebRequest GetWebRequest(Uri address) - { - var webRequest = base.GetWebRequest(address); - webRequest.Timeout = Timeout; - return webRequest; - } - } + private readonly record struct FileToUpload(string Name, string Filename, string ContentType, Stream Stream); } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/PlayerConnection.cs b/DXMainClient/Domain/Multiplayer/CnCNet/PlayerConnection.cs new file mode 100644 index 000000000..1ef154e03 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/PlayerConnection.cs @@ -0,0 +1,191 @@ +using System; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal abstract class PlayerConnection : IDisposable +{ + protected const int PlayerIdSize = sizeof(uint); + protected const int PlayerIdsSize = PlayerIdSize * 2; + protected const int SendTimeout = 10000; + protected const int MaximumPacketSize = 1024; + + protected CancellationToken CancellationToken; + protected Socket Socket; + protected EndPoint RemoteEndPoint; + + public uint PlayerId { get; protected set; } + + protected virtual int GameStartReceiveTimeout => 60000; + + protected virtual int GameInProgressReceiveTimeout => 10000; + + /// + /// Occurs when the connection was lost. + /// + public event EventHandler RaiseConnectionCutEvent; + + /// + /// Occurs when game data was received. + /// + public event EventHandler RaiseDataReceivedEvent; + + public void Dispose() + { +#if DEBUG + Logger.Log($"{GetType().Name}: Connection to {RemoteEndPoint} closed for player {PlayerId}."); +#else + Logger.Log($"{GetType().Name}: Connection closed for player {PlayerId}."); +#endif + Socket?.Close(); + } + + /// + /// Starts listening for game data and forwards it. + /// + public async ValueTask StartConnectionAsync() + { + await DoStartConnectionAsync().ConfigureAwait(false); + await ReceiveLoopAsync().ConfigureAwait(false); + } + + protected virtual ValueTask DoStartConnectionAsync() + => ValueTask.CompletedTask; + + protected abstract ValueTask DoReceiveDataAsync(Memory buffer, CancellationToken cancellation); + + protected abstract DataReceivedEventArgs ProcessReceivedData(Memory buffer, SocketReceiveFromResult socketReceiveFromResult); + + protected async ValueTask SendDataAsync(ReadOnlyMemory data) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(SendTimeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, CancellationToken); + + try + { +#if DEBUG +#if NETWORKTRACE + Logger.Log($"{GetType().Name}: Sending data from {Socket.LocalEndPoint} to {RemoteEndPoint} for player {PlayerId}: {BitConverter.ToString(data.Span.ToArray())}."); +#else + Logger.Log($"{GetType().Name}: Sending data from {Socket.LocalEndPoint} to {RemoteEndPoint} for player {PlayerId}."); +#endif +#endif + await Socket.SendToAsync(data, SocketFlags.None, RemoteEndPoint, linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (SocketException ex) + { +#if DEBUG + ProgramConstants.LogException(ex, $"Socket exception sending data to {RemoteEndPoint} for player {PlayerId}."); +#else + ProgramConstants.LogException(ex, $"Socket exception sending data for player {PlayerId}."); +#endif + OnRaiseConnectionCutEvent(EventArgs.Empty); + } + catch (ObjectDisposedException) + { + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + } + catch (OperationCanceledException) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Connection from {Socket.LocalEndPoint} to {RemoteEndPoint} timed out for player {PlayerId} when sending data."); +#else + Logger.Log($"{GetType().Name}: Connection timed out for player {PlayerId} when sending data."); +#endif + OnRaiseConnectionCutEvent(EventArgs.Empty); + } + } + + private async ValueTask ReceiveLoopAsync() + { + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(MaximumPacketSize); + int receiveTimeout = GameStartReceiveTimeout; + +#if DEBUG + Logger.Log($"{GetType().Name}: Start listening for {RemoteEndPoint} on {Socket.LocalEndPoint} for player {PlayerId}."); +#else + Logger.Log($"{GetType().Name}: Start listening for player {PlayerId}."); +#endif + + while (!CancellationToken.IsCancellationRequested) + { + Memory buffer = memoryOwner.Memory[..MaximumPacketSize]; + SocketReceiveFromResult socketReceiveFromResult; + using var timeoutCancellationTokenSource = new CancellationTokenSource(receiveTimeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, CancellationToken); + + try + { + socketReceiveFromResult = await DoReceiveDataAsync(buffer, linkedCancellationTokenSource.Token).ConfigureAwait(false); + RemoteEndPoint = socketReceiveFromResult.RemoteEndPoint; + } + catch (SocketException ex) + { +#if DEBUG + ProgramConstants.LogException(ex, $"Socket exception in {RemoteEndPoint} receive loop for player {PlayerId}."); +#else + ProgramConstants.LogException(ex, $"Socket exception in receive loop for player {PlayerId}."); +#endif + OnRaiseConnectionCutEvent(EventArgs.Empty); + + return; + } + catch (ObjectDisposedException) + { + return; + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Connection from {Socket.LocalEndPoint} to {RemoteEndPoint} timed out for player {PlayerId} when receiving data."); +#else + Logger.Log($"{GetType().Name}: Connection timed out for player {PlayerId} when receiving data."); +#endif + OnRaiseConnectionCutEvent(EventArgs.Empty); + + return; + } + + receiveTimeout = GameInProgressReceiveTimeout; + +#if DEBUG +#if NETWORKTRACE + Logger.Log($"{GetType().Name}: Received data from {RemoteEndPoint} on {Socket.LocalEndPoint} for player {PlayerId}: {BitConverter.ToString(buffer.Span.ToArray())}."); +#else + Logger.Log($"{GetType().Name}: Received data from {RemoteEndPoint} on {Socket.LocalEndPoint} for player {PlayerId}."); +#endif +#endif + + DataReceivedEventArgs dataReceivedEventArgs = ProcessReceivedData(buffer, socketReceiveFromResult); + + if (dataReceivedEventArgs is not null) + OnRaiseDataReceivedEvent(dataReceivedEventArgs); + } + } + + private void OnRaiseConnectionCutEvent(EventArgs e) + { + EventHandler raiseEvent = RaiseConnectionCutEvent; + + raiseEvent?.Invoke(this, e); + } + + private void OnRaiseDataReceivedEvent(DataReceivedEventArgs e) + { + EventHandler raiseEvent = RaiseDataReceivedEvent; + + raiseEvent?.Invoke(this, e); + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/Replays/GameDataJsonConverter.cs b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/GameDataJsonConverter.cs new file mode 100644 index 000000000..79f24b8ce --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/GameDataJsonConverter.cs @@ -0,0 +1,14 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.Replays; + +internal sealed class GameDataJsonConverter : JsonConverter> +{ + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(reader.GetBytesFromBase64()); + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + => writer.WriteBase64StringValue(value.Span); +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/Replays/Replay.cs b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/Replay.cs new file mode 100644 index 000000000..67de759e8 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/Replay.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.Replays; + +internal readonly record struct Replay( + [property: JsonPropertyName("i")] int Id, + [property: JsonPropertyName("s")] string Settings, + [property: JsonPropertyName("t")] DateTimeOffset Timestamp, + [property: JsonPropertyName("p")] uint RecordingPlayerId, + [property: JsonPropertyName("m")] Dictionary PlayerMappings, + [property: JsonPropertyName("d")] List Data, + [property: JsonPropertyName("v")] byte Version = 1); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayData.cs b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayData.cs new file mode 100644 index 000000000..ddc618d67 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayData.cs @@ -0,0 +1,10 @@ +using System; +using System.Text.Json.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.Replays; + +internal readonly record struct ReplayData( + [property: JsonPropertyName("t")] TimeSpan TimestampOffset, + [property: JsonPropertyName("p")] uint PlayerId, + [property: JsonPropertyName("g")][property: JsonConverter(typeof(GameDataJsonConverter))] ReadOnlyMemory GameData, + [property: JsonPropertyName("v")] byte Version = 1); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayHandler.cs b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayHandler.cs new file mode 100644 index 000000000..fc38e09bd --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/Replays/ReplayHandler.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet.Replays; + +internal sealed class ReplayHandler : IAsyncDisposable +{ + private readonly Dictionary replayFileStreams = new(); + + private DateTimeOffset startTimestamp; + private DirectoryInfo replayDirectory; + private bool gameStarted; + private int replayId; + private uint gameLocalPlayerId; + + public void SetupRecording(int replayId, uint gameLocalPlayerId) + { + this.replayId = replayId; + this.gameLocalPlayerId = gameLocalPlayerId; + startTimestamp = DateTimeOffset.Now; + replayDirectory = SafePath.GetDirectory(ProgramConstants.GamePath, ProgramConstants.REPLAYS_DIRECTORY, replayId.ToString(CultureInfo.InvariantCulture)); + gameStarted = false; + + replayDirectory.Create(); + replayFileStreams.Add(gameLocalPlayerId, CreateReplayFileStream()); + } + + public async ValueTask StopRecordingAsync(List gamePlayerIds, List playerInfos, List v3GameTunnelHandlers) + { + foreach (V3GameTunnelHandler v3GameTunnelHandler in v3GameTunnelHandlers) + { + v3GameTunnelHandler.RaiseRemoteHostDataReceivedEvent -= RemoteHostConnection_DataReceivedAsync; + v3GameTunnelHandler.RaiseLocalGameDataReceivedEvent -= LocalGameConnection_DataReceivedAsync; + } + + if (!(replayDirectory?.Exists ?? false)) + return; + + FileInfo spawnFile = SafePath.GetFile(replayDirectory.FullName, ProgramConstants.SPAWNER_SETTINGS); + string settings = null; + Dictionary playerMappings = new(); + + if (spawnFile.Exists) + { + settings = await File.ReadAllTextAsync(spawnFile.FullName, CancellationToken.None).ConfigureAwait(false); + var spawnIni = new IniFile(spawnFile.FullName); + IniSection settingsSection = spawnIni.GetSection("Settings"); + string playerName = settingsSection.GetStringValue("Name", null); + uint playerId = gamePlayerIds[playerInfos.Single(q => q.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase)).Index]; + + playerMappings.Add(playerId, playerName); + + for (int i = 1; i < settingsSection.GetIntValue("PlayerCount", 0); i++) + { + IniSection otherPlayerSection = spawnIni.GetSection($"Other{i}"); + + if (otherPlayerSection is not null) + { + playerName = otherPlayerSection.GetStringValue("Name", null); + playerId = gamePlayerIds[playerInfos.Single(q => q.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase)).Index]; + + playerMappings.Add(playerId, playerName); + } + } + } + + List replayDataList = await GenerateReplayDataAsync().ConfigureAwait(false); + var replay = new Replay(replayId, settings, startTimestamp, gameLocalPlayerId, playerMappings, replayDataList.OrderBy(q => q.TimestampOffset).ToList()); + var tempReplayFileStream = new MemoryStream(); + + await using (tempReplayFileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(tempReplayFileStream, replay, cancellationToken: CancellationToken.None).ConfigureAwait(false); + + tempReplayFileStream.Position = 0L; + + FileStream replayFileStream = new( + SafePath.CombineFilePath(replayDirectory.Parent.FullName, FormattableString.Invariant($"{replayId}.cnc")), + new FileStreamOptions + { + Access = FileAccess.Write, + Mode = FileMode.CreateNew, + Options = FileOptions.Asynchronous + }); + + await using (replayFileStream.ConfigureAwait(false)) + { + var compressionStream = new GZipStream(replayFileStream, CompressionMode.Compress); + + await using (compressionStream.ConfigureAwait(false)) + { + await tempReplayFileStream.CopyToAsync(compressionStream, CancellationToken.None).ConfigureAwait(false); + } + } + } + + SafePath.DeleteFileIfExists(spawnFile.FullName); + } + + public async ValueTask DisposeAsync() + { + foreach ((_, FileStream fileStream) in replayFileStreams) + await fileStream.DisposeAsync().ConfigureAwait(false); + + replayFileStreams.Clear(); + replayDirectory?.Refresh(); + + if (replayDirectory?.Exists ?? false) + SafePath.DeleteDirectoryIfExists(true, replayDirectory.FullName); + } + + public void RemoteHostConnection_DataReceivedAsync(object sender, DataReceivedEventArgs e) + => SaveReplayDataAsync(((V3RemotePlayerConnection)sender).PlayerId, e).HandleTask(); + + public void LocalGameConnection_DataReceivedAsync(object sender, DataReceivedEventArgs e) + { + if (!gameStarted) + { + gameStarted = true; + + FileInfo spawnFileInfo = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS); + + spawnFileInfo.CopyTo(SafePath.CombineFilePath(replayDirectory.FullName, spawnFileInfo.Name)); + } + + SaveReplayDataAsync(((V3LocalPlayerConnection)sender).PlayerId, e).HandleTask(); + } + + private async ValueTask> GenerateReplayDataAsync() + { + var replayDataList = new List(); + + foreach (FileStream fileStream in replayFileStreams.Values.Where(q => q.Length > 0L)) + { + await fileStream.WriteAsync(new UTF8Encoding().GetBytes(new[] { ']' })).ConfigureAwait(false); + + fileStream.Position = 0L; + + replayDataList.AddRange(await JsonSerializer.DeserializeAsync>( + fileStream, new JsonSerializerOptions { AllowTrailingCommas = true }, cancellationToken: CancellationToken.None).ConfigureAwait(false)); + } + + return replayDataList; + } + + private async ValueTask SaveReplayDataAsync(uint playerId, DataReceivedEventArgs e) + { + if (!replayFileStreams.TryGetValue(playerId, out FileStream fileStream)) + { + fileStream = CreateReplayFileStream(); + + if (!replayFileStreams.TryAdd(playerId, fileStream)) + await fileStream.DisposeAsync().ConfigureAwait(false); + + replayFileStreams.TryGetValue(playerId, out fileStream); + } + + if (fileStream.Position is 0L) + await fileStream.WriteAsync(new UTF8Encoding().GetBytes(new[] { '[' })).ConfigureAwait(false); + + var replayData = new ReplayData(e.Timestamp - startTimestamp, playerId, e.GameData); + var tempStream = new MemoryStream(); + + await using (tempStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(tempStream, replayData, cancellationToken: CancellationToken.None).ConfigureAwait(false); + await tempStream.WriteAsync(new UTF8Encoding().GetBytes(new[] { ',' })).ConfigureAwait(false); + + tempStream.Position = 0L; + + await tempStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + } + + private FileStream CreateReplayFileStream() + => new( + SafePath.CombineFilePath(replayDirectory.FullName, Guid.NewGuid().ToString()), + new FileStreamOptions + { + Access = FileAccess.ReadWrite, + Mode = FileMode.CreateNew, + Options = FileOptions.Asynchronous | FileOptions.DeleteOnClose + }); +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/TunnelHandler.cs b/DXMainClient/Domain/Multiplayer/CnCNet/TunnelHandler.cs index 59b3d2f49..4ad510682 100644 --- a/DXMainClient/Domain/Multiplayer/CnCNet/TunnelHandler.cs +++ b/DXMainClient/Domain/Multiplayer/CnCNet/TunnelHandler.cs @@ -1,19 +1,21 @@ -using ClientCore; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; using DTAClient.Online; using Microsoft.Xna.Framework; using Rampastring.Tools; using Rampastring.XNAUI; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Text; -using System.Threading.Tasks; -using System.Linq; +using TaskExtensions = ClientCore.Extensions.TaskExtensions; namespace DTAClient.Domain.Multiplayer.CnCNet { - public class TunnelHandler : GameComponent + internal sealed class TunnelHandler : GameComponent { /// /// Determines the time between pinging the current tunnel (if it's set). @@ -22,19 +24,28 @@ public class TunnelHandler : GameComponent /// /// A reciprocal to the value which determines how frequent the full tunnel - /// refresh would be done instead of just pinging the current tunnel (1/N of + /// refresh would be done instead of just pinging the current tunnel (1/N of /// current tunnel ping refreshes would be substituted by a full list refresh). - /// Multiply by to get the interval + /// Multiply by to get the interval /// between full list refreshes. /// private const uint CYCLES_PER_TUNNEL_LIST_REFRESH = 6; - private const int SUPPORTED_TUNNEL_VERSION = 2; + private readonly WindowManager wm; + + private TimeSpan timeSinceTunnelRefresh = TimeSpan.MaxValue; + private uint skipCount; + + public event EventHandler TunnelsRefreshed; + + public event EventHandler CurrentTunnelPinged; + + public event Action TunnelPinged; - public TunnelHandler(WindowManager wm, CnCNetManager connectionManager) : base(wm.Game) + public TunnelHandler(WindowManager wm, CnCNetManager connectionManager) + : base(wm.Game) { this.wm = wm; - this.connectionManager = connectionManager; wm.Game.Components.Add(this); @@ -45,30 +56,15 @@ public TunnelHandler(WindowManager wm, CnCNetManager connectionManager) : base(w connectionManager.ConnectionLost += ConnectionManager_ConnectionLost; } - public List Tunnels { get; private set; } = new List(); - public CnCNetTunnel CurrentTunnel { get; set; } = null; + public List Tunnels { get; private set; } = new(); - public event EventHandler TunnelsRefreshed; - public event EventHandler CurrentTunnelPinged; - public event Action TunnelPinged; - - private WindowManager wm; - private CnCNetManager connectionManager; - - private TimeSpan timeSinceTunnelRefresh = TimeSpan.MaxValue; - private uint skipCount = 0; + public CnCNetTunnel CurrentTunnel { get; set; } private void DoTunnelPinged(int index) - { - if (TunnelPinged != null) - wm.AddCallback(TunnelPinged, index); - } + => wm.AddCallback(() => TunnelPinged?.Invoke(index)); private void DoCurrentTunnelPinged() - { - if (CurrentTunnelPinged != null) - wm.AddCallback(CurrentTunnelPinged, this, EventArgs.Empty); - } + => wm.AddCallback(() => CurrentTunnelPinged?.Invoke(this, EventArgs.Empty)); private void ConnectionManager_Connected(object sender, EventArgs e) => Enabled = true; @@ -76,33 +72,25 @@ private void DoCurrentTunnelPinged() private void ConnectionManager_Disconnected(object sender, EventArgs e) => Enabled = false; - private void RefreshTunnelsAsync() + private async ValueTask RefreshTunnelsAsync(CancellationToken cancellationToken) { - Task.Factory.StartNew(() => - { - List tunnels = RefreshTunnels(); - wm.AddCallback(new Action>(HandleRefreshedTunnels), tunnels); - }); + List tunnels = await DoRefreshTunnelsAsync(cancellationToken).ConfigureAwait(false); + wm.AddCallback(() => HandleRefreshedTunnelsAsync(tunnels, cancellationToken).HandleTask()); } - private void HandleRefreshedTunnels(List tunnels) + private async ValueTask HandleRefreshedTunnelsAsync(List tunnels, CancellationToken cancellationToken) { if (tunnels.Count > 0) Tunnels = tunnels; TunnelsRefreshed?.Invoke(this, EventArgs.Empty); - Task[] pingTasks = new Task[Tunnels.Count]; - - for (int i = 0; i < Tunnels.Count; i++) - { - if (UserINISettings.Instance.PingUnofficialCnCNetTunnels || Tunnels[i].Official || Tunnels[i].Recommended) - pingTasks[i] = PingListTunnelAsync(i); - } + await TaskExtensions.WhenAllSafe(Tunnels.Select(q => PingListTunnelAsync(q, cancellationToken))).ConfigureAwait(false); if (CurrentTunnel != null) { - var updatedTunnel = Tunnels.Find(t => t.Address == CurrentTunnel.Address && t.Port == CurrentTunnel.Port); + CnCNetTunnel updatedTunnel = Tunnels.Find(t => t.Hash.Equals(CurrentTunnel.Hash, StringComparison.OrdinalIgnoreCase)); + if (updatedTunnel != null) { // don't re-ping if the tunnel still exists in list, just update the tunnel instance and @@ -113,89 +101,84 @@ private void HandleRefreshedTunnels(List tunnels) else { // tunnel is not in the list anymore so it's not updated with a list instance and pinged - PingCurrentTunnelAsync(); + PingCurrentTunnelAsync(false, cancellationToken).HandleTask(); } } } - private Task PingListTunnelAsync(int index) + private async Task PingListTunnelAsync(CnCNetTunnel tunnel, CancellationToken cancellationToken) { - return Task.Factory.StartNew(() => - { - Tunnels[index].UpdatePing(); - DoTunnelPinged(index); - }); + if (!UserINISettings.Instance.PingUnofficialCnCNetTunnels && !tunnel.Official && !tunnel.Recommended) + return; + + await tunnel.UpdatePingAsync(cancellationToken).ConfigureAwait(false); + + int tunnelIndex = Tunnels.FindIndex(t => t.Hash.Equals(tunnel.Hash, StringComparison.OrdinalIgnoreCase)); + + DoTunnelPinged(tunnelIndex); } - private Task PingCurrentTunnelAsync(bool checkTunnelList = false) + private async ValueTask PingCurrentTunnelAsync(bool checkTunnelList, CancellationToken cancellationToken) { - return Task.Factory.StartNew(() => + await CurrentTunnel.UpdatePingAsync(cancellationToken).ConfigureAwait(false); + DoCurrentTunnelPinged(); + + if (checkTunnelList) { - CurrentTunnel.UpdatePing(); - DoCurrentTunnelPinged(); + int tunnelIndex = Tunnels.FindIndex(t => t.Hash.Equals(CurrentTunnel.Hash, StringComparison.OrdinalIgnoreCase)); - if (checkTunnelList) - { - int tunnelIndex = Tunnels.FindIndex(t => t.Address == CurrentTunnel.Address && t.Port == CurrentTunnel.Port); - if (tunnelIndex > -1) - DoTunnelPinged(tunnelIndex); - } - }); + if (tunnelIndex > -1) + DoTunnelPinged(tunnelIndex); + } } /// /// Downloads and parses the list of CnCNet tunnels. /// /// A list of tunnel servers. - private List RefreshTunnels() + private static async ValueTask> DoRefreshTunnelsAsync(CancellationToken cancellationToken) { FileInfo tunnelCacheFile = SafePath.GetFile(ProgramConstants.ClientUserFilesPath, "tunnel_cache"); - - List returnValue = new List(); - - WebClient client = new WebClient(); - - byte[] data; + var returnValue = new List(); + string data; Logger.Log("Fetching tunnel server info."); try { - data = client.DownloadData(MainClientConstants.CNCNET_TUNNEL_LIST_URL); + data = await Constants.CnCNetHttpClient.GetStringAsync(new Uri(ProgramConstants.CNCNET_TUNNEL_LIST_URL), cancellationToken).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) { - Logger.Log("Error when downloading tunnel server info: " + ex.Message); - Logger.Log("Retrying."); + ProgramConstants.LogException(ex, "Error when downloading tunnel server info. Retrying."); try { - data = client.DownloadData(MainClientConstants.CNCNET_TUNNEL_LIST_URL); + data = await Constants.CnCNetHttpClient.GetStringAsync(new Uri(ProgramConstants.CNCNET_TUNNEL_LIST_URL), cancellationToken).ConfigureAwait(false); } - catch + catch (Exception ex1) when (ex1 is HttpRequestException or OperationCanceledException) { + ProgramConstants.LogException(ex1); if (!tunnelCacheFile.Exists) { Logger.Log("Tunnel cache file doesn't exist!"); return returnValue; } - else - { - Logger.Log("Fetching tunnel server list failed. Using cached tunnel data."); - data = File.ReadAllBytes(tunnelCacheFile.FullName); - } + + Logger.Log("Fetching tunnel server list failed. Using cached tunnel data."); + data = await File.ReadAllTextAsync(tunnelCacheFile.FullName, cancellationToken).ConfigureAwait(false); } } - string convertedData = Encoding.Default.GetString(data); + string[] serverList = data.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + bool hasIPv6Internet = NetworkHelper.HasIPv6Internet(); + bool hasIPv4Internet = NetworkHelper.HasIPv4Internet(); - string[] serverList = convertedData.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); - - // skip first header item ("address;country;countrycode;name;password;clients;maxclients;official;latitude;longitude;version;distance") + // skip the header foreach (string serverInfo in serverList.Skip(1)) { try { - CnCNetTunnel tunnel = CnCNetTunnel.Parse(serverInfo); + var tunnel = CnCNetTunnel.Parse(serverInfo, hasIPv6Internet, hasIPv4Internet); if (tunnel == null) continue; @@ -203,35 +186,41 @@ private List RefreshTunnels() if (tunnel.RequiresPassword) continue; - if (tunnel.Version != SUPPORTED_TUNNEL_VERSION) + if (tunnel.Version is not Constants.TUNNEL_VERSION_2 and not Constants.TUNNEL_VERSION_3) + continue; + + if (tunnel.Version is Constants.TUNNEL_VERSION_2 && !UserINISettings.Instance.UseLegacyTunnels) + continue; + + if (tunnel.Version is Constants.TUNNEL_VERSION_3 && UserINISettings.Instance.UseLegacyTunnels) continue; returnValue.Add(tunnel); } catch (Exception ex) { - Logger.Log("Caught an exception when parsing a tunnel server: " + ex.Message); + ProgramConstants.LogException(ex, "Caught an exception when parsing a tunnel server."); } } - if (returnValue.Count > 0) + if (!returnValue.Any()) + return returnValue; + + try { - try - { - if (tunnelCacheFile.Exists) - tunnelCacheFile.Delete(); + if (tunnelCacheFile.Exists) + tunnelCacheFile.Delete(); - DirectoryInfo clientDirectoryInfo = SafePath.GetDirectory(ProgramConstants.ClientUserFilesPath); + DirectoryInfo clientDirectoryInfo = SafePath.GetDirectory(ProgramConstants.ClientUserFilesPath); - if (!clientDirectoryInfo.Exists) - clientDirectoryInfo.Create(); + if (!clientDirectoryInfo.Exists) + clientDirectoryInfo.Create(); - File.WriteAllBytes(tunnelCacheFile.FullName, data); - } - catch (Exception ex) - { - Logger.Log("Refreshing tunnel cache file failed! Returned error: " + ex.Message); - } + await File.WriteAllTextAsync(tunnelCacheFile.FullName, data, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, "Refreshing tunnel cache file failed!"); } return returnValue; @@ -244,20 +233,22 @@ public override void Update(GameTime gameTime) if (skipCount % CYCLES_PER_TUNNEL_LIST_REFRESH == 0) { skipCount = 0; - RefreshTunnelsAsync(); + RefreshTunnelsAsync(CancellationToken.None).HandleTask(); } else if (CurrentTunnel != null) { - PingCurrentTunnelAsync(true); + PingCurrentTunnelAsync(true, CancellationToken.None).HandleTask(); } timeSinceTunnelRefresh = TimeSpan.Zero; skipCount++; } else + { timeSinceTunnelRefresh += gameTime.ElapsedGameTime; + } base.Update(gameTime); } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/InternetGatewayDevice.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/InternetGatewayDevice.cs new file mode 100644 index 000000000..cf18dfa79 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/InternetGatewayDevice.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.ServiceModel; +using System.ServiceModel.Description; +using System.Text; +using System.Xml; +using System.ServiceModel.Channels; +using ClientCore; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +internal sealed record InternetGatewayDevice( + IEnumerable Locations, + string Server, + UPnPDescription UPnPDescription, + Uri PreferredLocation) +{ + private const uint IpLeaseTimeInSeconds = 4 * 60 * 60; + private const ushort IanaUdpProtocolNumber = 17; + private const string PortMappingDescription = "CnCNet"; + + public async Task OpenIpV4PortAsync(IPAddress ipAddress, ushort port, CancellationToken cancellationToken) + { + Logger.Log($"P2P: Opening IPV4 UDP port {port}."); + + int uPnPVersion = GetDeviceUPnPVersion(); + + switch (uPnPVersion) + { + case 2: + var addAnyPortMappingRequest = new AddAnyPortMappingRequest(string.Empty, port, "UDP", port, ipAddress.ToString(), 1, PortMappingDescription, IpLeaseTimeInSeconds); + AddAnyPortMappingResponse addAnyPortMappingResponse = await DoSoapActionAsync( + addAnyPortMappingRequest, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.AddAnyPortMapping, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + port = addAnyPortMappingResponse.ReservedPort; + + break; + case 1: + var addPortMappingRequest = new AddPortMappingRequest(string.Empty, port, "UDP", port, ipAddress.ToString(), 1, PortMappingDescription, IpLeaseTimeInSeconds); + + await DoSoapActionAsync( + addPortMappingRequest, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", "AddPortMapping", AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + break; + default: + throw new ArgumentException($"P2P: UPnP version {uPnPVersion} is not supported."); + } + + Logger.Log($"P2P: Opened IPV4 UDP port {port}."); + + return port; + } + + public async Task CloseIpV4PortAsync(ushort port, CancellationToken cancellationToken) + { + try + { + Logger.Log($"P2P: Deleting IPV4 UDP port {port}."); + + int uPnPVersion = GetDeviceUPnPVersion(); + + switch (uPnPVersion) + { + case 2: + var deletePortMappingRequestV2 = new DeletePortMappingRequestV2(string.Empty, port, "UDP"); + + await DoSoapActionAsync( + deletePortMappingRequestV2, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.DeletePortMapping, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + break; + case 1: + var deletePortMappingRequestV1 = new DeletePortMappingRequestV1(string.Empty, port, "UDP"); + + await DoSoapActionAsync( + deletePortMappingRequestV1, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.DeletePortMapping, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + break; + default: + throw new ArgumentException($"P2P: UPnP version {uPnPVersion} is not supported."); + } + + Logger.Log($"P2P: Deleted IPV4 UDP port {port}."); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, $"P2P: Could not close UPnP IPV4 port {port}."); + } + } + + public async Task GetExternalIpV4AddressAsync(CancellationToken cancellationToken) + { + Logger.Log("P2P: Requesting external IP address."); + + int uPnPVersion = GetDeviceUPnPVersion(); + IPAddress ipAddress = null; + + try + { + switch (uPnPVersion) + { + case 2: + GetExternalIPAddressResponseV2 getExternalIpAddressResponseV2 = await DoSoapActionAsync( + default, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.GetExternalIPAddress, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + ipAddress = string.IsNullOrWhiteSpace(getExternalIpAddressResponseV2.ExternalIPAddress) ? null : IPAddress.Parse(getExternalIpAddressResponseV2.ExternalIPAddress); + + break; + case 1: + GetExternalIPAddressResponseV1 getExternalIpAddressResponseV1 = await DoSoapActionAsync( + default, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.GetExternalIPAddress, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + ipAddress = string.IsNullOrWhiteSpace(getExternalIpAddressResponseV1.ExternalIPAddress) ? null : IPAddress.Parse(getExternalIpAddressResponseV1.ExternalIPAddress); + break; + default: + throw new ArgumentException($"P2P: UPnP version {uPnPVersion} is not supported."); + } + +#if DEBUG + Logger.Log($"P2P: Received external IPv4 address {ipAddress}."); +#else + Logger.Log($"P2P: Received external IPv4 address."); +#endif + } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } + + return ipAddress; + } + + public async Task GetNatRsipStatusAsync(CancellationToken cancellationToken) + { + Logger.Log("P2P: Checking NAT status."); + + int uPnPVersion = GetDeviceUPnPVersion(); + bool? natEnabled = null; + + try + { + switch (uPnPVersion) + { + case 2: + GetNatRsipStatusResponseV2 getNatRsipStatusResponseV2 = await DoSoapActionAsync( + default, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.GetNatRsipStatus, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + natEnabled = getNatRsipStatusResponseV2.NatEnabled; + + break; + case 1: + GetNatRsipStatusResponseV1 getNatRsipStatusResponseV1 = await DoSoapActionAsync( + default, $"{UPnPConstants.WanIpConnection}:{uPnPVersion}", UPnPConstants.GetNatRsipStatus, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + + natEnabled = getNatRsipStatusResponseV1.NatEnabled; + break; + default: + throw new ArgumentException($"P2P: UPnP version {uPnPVersion} is not supported."); + } + + Logger.Log($"P2P: Received NAT status {natEnabled}."); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } + + return natEnabled; + } + + public async ValueTask<(bool? FirewallEnabled, bool? InboundPinholeAllowed)> GetIpV6FirewallStatusAsync(CancellationToken cancellationToken) + { + try + { + Logger.Log("P2P: Checking IPV6 firewall status."); + + GetFirewallStatusResponse response = await DoSoapActionAsync( + default, $"{UPnPConstants.WanIpv6FirewallControl}:1", UPnPConstants.GetFirewallStatus, AddressFamily.InterNetworkV6, cancellationToken).ConfigureAwait(false); + + Logger.Log($"P2P: Received IPV6 firewall status '{response.FirewallEnabled}' and port mapping allowed '{response.InboundPinholeAllowed}'."); + + return (response.FirewallEnabled, response.InboundPinholeAllowed); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + ProgramConstants.LogException(ex); + + return (null, null); + } + } + + public async Task OpenIpV6PortAsync(IPAddress ipAddress, ushort port, CancellationToken cancellationToken) + { + Logger.Log($"P2P: Opening IPV6 UDP port {port}."); + + var request = new AddPinholeRequest(string.Empty, port, ipAddress.ToString(), port, IanaUdpProtocolNumber, IpLeaseTimeInSeconds); + AddPinholeResponse response = await DoSoapActionAsync( + request, $"{UPnPConstants.WanIpv6FirewallControl}:1", UPnPConstants.AddPinhole, AddressFamily.InterNetworkV6, cancellationToken).ConfigureAwait(false); + + Logger.Log($"P2P: Opened IPV6 UDP port {port} with ID {response.UniqueId}."); + + return response.UniqueId; + } + + public async Task CloseIpV6PortAsync(ushort uniqueId, CancellationToken cancellationToken) + { + try + { + Logger.Log($"P2P: Deleting IPV6 UDP port with ID {uniqueId}."); + await DoSoapActionAsync( + new(uniqueId), $"{UPnPConstants.WanIpv6FirewallControl}:1", UPnPConstants.DeletePinhole, AddressFamily.InterNetworkV6, cancellationToken).ConfigureAwait(false); + Logger.Log($"P2P: Deleted IPV6 UDP port with ID {uniqueId}."); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, $"P2P: Could not close UPnP IPV6 port with id {uniqueId}."); + } + } + + private async ValueTask DoSoapActionAsync( + TRequest request, string wanConnectionDeviceService, string action, AddressFamily addressFamily, CancellationToken cancellationToken) + { + try + { + (ServiceListItem service, Uri serviceUri, string serviceType) = GetSoapActionParameters(wanConnectionDeviceService, addressFamily); + string soapAction = $"\"{service.ServiceType}#{action}\""; + + return await ExecuteSoapAction(serviceUri, soapAction, serviceType, request, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new($"P2P: {action} error/not supported using {addressFamily}.", ex); + } + } + + private static async ValueTask ExecuteSoapAction( + Uri serviceUri, string soapAction, string defaultNamespace, TRequest request, CancellationToken cancellationToken) + { + UPnPHandler.HttpClient.DefaultRequestHeaders.Remove("SOAPAction"); + UPnPHandler.HttpClient.DefaultRequestHeaders.Add("SOAPAction", soapAction); + + var xmlSerializerFormatAttribute = new XmlSerializerFormatAttribute + { + Style = OperationFormatStyle.Rpc, + Use = OperationFormatUse.Encoded + }; + var requestTypedMessageConverter = TypedMessageConverter.Create(typeof(TRequest), soapAction, defaultNamespace, xmlSerializerFormatAttribute); + using var requestMessage = requestTypedMessageConverter.ToMessage(request); + var requestStream = new MemoryStream(); + HttpResponseMessage httpResponseMessage; + + await using (requestStream) + { + var writer = XmlWriter.Create( + requestStream, + new() + { + OmitXmlDeclaration = true, + Async = true, + Encoding = new UTF8Encoding() + }); + + await using (writer.ConfigureAwait(false)) + { + requestMessage.WriteMessage(writer); + await writer.FlushAsync().ConfigureAwait(false); + } + + requestStream.Position = 0L; + + using var content = new StreamContent(requestStream); + + content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/xml"); + + httpResponseMessage = await UPnPHandler.HttpClient.PostAsync(serviceUri, content, cancellationToken).ConfigureAwait(false); + } + + using (httpResponseMessage) + { + Stream stream = await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + await using (stream.ConfigureAwait(false)) + { + try + { + httpResponseMessage.EnsureSuccessStatusCode(); + } + catch (HttpRequestException ex) + { + using var reader = new StreamReader(stream); + string error = await reader.ReadToEndAsync(CancellationToken.None).ConfigureAwait(false); + + ProgramConstants.LogException(ex, $"P2P: UPnP error {ex.StatusCode}:{error}."); + + throw; + } + + using var envelopeReader = XmlDictionaryReader.CreateTextReader(stream, new()); + using var responseMessage = Message.CreateMessage(envelopeReader, int.MaxValue, MessageVersion.Soap11WSAddressingAugust2004); + var responseTypedMessageConverter = TypedMessageConverter.Create(typeof(TResponse), null, defaultNamespace, xmlSerializerFormatAttribute); + + return (TResponse)responseTypedMessageConverter.FromMessage(responseMessage); + } + } + } + + private (ServiceListItem WanIpConnectionService, Uri ServiceUri, string ServiceType) GetSoapActionParameters(string wanConnectionDeviceService, AddressFamily addressFamily) + { + Uri location = addressFamily switch + { + AddressFamily.InterNetwork when Locations.Any(q => q.HostNameType is UriHostNameType.IPv4) => + Locations.FirstOrDefault(q => q.HostNameType is UriHostNameType.IPv4), + AddressFamily.InterNetworkV6 when Locations.Any(q => q.HostNameType is UriHostNameType.IPv6 && !NetworkHelper.IsPrivateIpAddress(IPAddress.Parse(q.IdnHost))) => + Locations.FirstOrDefault(q => q.HostNameType is UriHostNameType.IPv6), + AddressFamily.InterNetworkV6 when Locations.Any(q => q.HostNameType is UriHostNameType.IPv6 && NetworkHelper.IsPrivateIpAddress(IPAddress.Parse(q.IdnHost))) => + Locations.FirstOrDefault(q => q.HostNameType is UriHostNameType.IPv6), + _ => PreferredLocation + }; + int uPnPVersion = GetDeviceUPnPVersion(); + Device wanDevice = UPnPDescription.Device.DeviceList.Single(q => q.DeviceType.Equals($"{UPnPConstants.UPnPWanDevice}:{uPnPVersion}", StringComparison.OrdinalIgnoreCase)); + Device wanConnectionDevice = wanDevice.DeviceList.Single(q => q.DeviceType.Equals($"{UPnPConstants.UPnPWanConnectionDevice}:{uPnPVersion}", StringComparison.OrdinalIgnoreCase)); + string serviceType = $"{UPnPConstants.UPnPServiceNamespace}:{wanConnectionDeviceService}"; + ServiceListItem wanIpConnectionService = wanConnectionDevice.ServiceList.Single(q => q.ServiceType.Equals(serviceType, StringComparison.OrdinalIgnoreCase)); + Uri serviceUri = NetworkHelper.FormatUri(location.Scheme, location, (ushort)location.Port, wanIpConnectionService.ControlUrl); + + return new(wanIpConnectionService, serviceUri, serviceType); + } + + private int GetDeviceUPnPVersion() + { + return $"{UPnPConstants.UPnPInternetGatewayDevice}:2".Equals(UPnPDescription.Device.DeviceType, StringComparison.OrdinalIgnoreCase) ? 2 + : $"{UPnPConstants.UPnPInternetGatewayDevice}:1".Equals(UPnPDescription.Device.DeviceType, StringComparison.OrdinalIgnoreCase) ? 1 : 0; + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingRequest.cs new file mode 100644 index 000000000..4671c024e --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingRequest.cs @@ -0,0 +1,14 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.AddAnyPortMapping, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct AddAnyPortMappingRequest( + [property: MessageBodyMember(Name = "NewRemoteHost")] string RemoteHost, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewExternalPort")] ushort ExternalPort, + [property: MessageBodyMember(Name = "NewProtocol")] string Protocol, // TCP or UDP + [property: MessageBodyMember(Name = "NewInternalPort")] ushort InternalPort, + [property: MessageBodyMember(Name = "NewInternalClient")] string InternalClient, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewEnabled")] byte Enabled, // bool + [property: MessageBodyMember(Name = "NewPortMappingDescription")] string PortMappingDescription, + [property: MessageBodyMember(Name = "NewLeaseDuration")] uint LeaseDuration); // in seconds, 1-604800 \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingResponse.cs new file mode 100644 index 000000000..0a7251169 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddAnyPortMappingResponse.cs @@ -0,0 +1,7 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.AddAnyPortMapping}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct AddAnyPortMappingResponse( + [property: MessageBodyMember(Name = "NewReservedPort")] ushort ReservedPort); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeRequest.cs new file mode 100644 index 000000000..7a24ff8b3 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeRequest.cs @@ -0,0 +1,12 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.AddPinhole, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct AddPinholeRequest( + [property: MessageBodyMember(Name = "RemoteHost")] string RemoteHost, + [property: MessageBodyMember(Name = "RemotePort")] ushort RemotePort, // 0 = wildcard + [property: MessageBodyMember(Name = "InternalClient")] string InternalClient, + [property: MessageBodyMember(Name = "InternalPort")] ushort InternalPort, // 0 = wildcard + [property: MessageBodyMember(Name = "Protocol")] ushort Protocol, // 17 = UDP + [property: MessageBodyMember(Name = "LeaseTime")] uint LeaseTime); // in seconds, 1-86400 \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeResponse.cs new file mode 100644 index 000000000..f226e87b1 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPinholeResponse.cs @@ -0,0 +1,7 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.AddPinhole}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct AddPinholeResponse( + [property: MessageBodyMember(Name = "UniqueID")] ushort UniqueId); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingRequest.cs new file mode 100644 index 000000000..1e146b10b --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingRequest.cs @@ -0,0 +1,14 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.AddPortMapping, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct AddPortMappingRequest( + [property: MessageBodyMember(Name = "NewRemoteHost")] string RemoteHost, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewExternalPort")] ushort ExternalPort, + [property: MessageBodyMember(Name = "NewProtocol")] string Protocol, // TCP or UDP + [property: MessageBodyMember(Name = "NewInternalPort")] ushort InternalPort, + [property: MessageBodyMember(Name = "NewInternalClient")] string InternalClient, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewEnabled")] byte Enabled, // bool + [property: MessageBodyMember(Name = "NewPortMappingDescription")] string PortMappingDescription, + [property: MessageBodyMember(Name = "NewLeaseDuration")] uint LeaseDuration); // in seconds, 1-604800 \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingResponse.cs new file mode 100644 index 000000000..3c4307fca --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/AddPortMappingResponse.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.AddPortMapping}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct AddPortMappingResponse; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeRequest.cs new file mode 100644 index 000000000..f9cd59697 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeRequest.cs @@ -0,0 +1,7 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.DeletePinhole, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct DeletePinholeRequest( + [property: MessageBodyMember(Name = "UniqueID")] ushort UniqueId); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeResponse.cs new file mode 100644 index 000000000..fb143a6cf --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePinholeResponse.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.DeletePinhole}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct DeletePinholeResponse; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV1.cs new file mode 100644 index 000000000..bdb3477ac --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV1.cs @@ -0,0 +1,9 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.DeletePortMapping, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct DeletePortMappingRequestV1( + [property: MessageBodyMember(Name = "NewRemoteHost")] string RemoteHost, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewExternalPort")] ushort ExternalPort, + [property: MessageBodyMember(Name = "NewProtocol")] string Protocol); // TCP or UDP \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV2.cs new file mode 100644 index 000000000..882e73dc8 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingRequestV2.cs @@ -0,0 +1,9 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.DeletePortMapping, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct DeletePortMappingRequestV2( + [property: MessageBodyMember(Name = "NewRemoteHost")] string RemoteHost, // “x.x.x.x” or empty string + [property: MessageBodyMember(Name = "NewExternalPort")] ushort ExternalPort, + [property: MessageBodyMember(Name = "NewProtocol")] string Protocol); // TCP or UDP \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV1.cs new file mode 100644 index 000000000..b7f7339b8 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV1.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.DeletePortMapping}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct DeletePortMappingResponseV1; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV2.cs new file mode 100644 index 000000000..5dbd5577d --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/DeletePortMappingResponseV2.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.DeletePortMapping}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct DeletePortMappingResponseV2; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV1.cs new file mode 100644 index 000000000..897bf7096 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV1.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.GetExternalIPAddress, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct GetExternalIPAddressRequestV1; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV2.cs new file mode 100644 index 000000000..fc26e3646 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressRequestV2.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.GetExternalIPAddress, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct GetExternalIPAddressRequestV2; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV1.cs new file mode 100644 index 000000000..a2870b23a --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV1.cs @@ -0,0 +1,7 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.GetExternalIPAddress}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +internal readonly record struct GetExternalIPAddressResponseV1( + [property: MessageBodyMember(Name = "NewExternalIPAddress")] string ExternalIPAddress); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV2.cs new file mode 100644 index 000000000..c3e3cc8ec --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetExternalIPAddressResponseV2.cs @@ -0,0 +1,7 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.GetExternalIPAddress}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +internal readonly record struct GetExternalIPAddressResponseV2( + [property: MessageBodyMember(Name = "NewExternalIPAddress")] string ExternalIPAddress); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusRequest.cs new file mode 100644 index 000000000..7f28fc9f7 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusRequest.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.GetFirewallStatus, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct GetFirewallStatusRequest; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusResponse.cs new file mode 100644 index 000000000..5aff79d08 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetFirewallStatusResponse.cs @@ -0,0 +1,8 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.GetFirewallStatus}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpv6FirewallControl}:1")] +internal readonly record struct GetFirewallStatusResponse( + [property: MessageBodyMember(Name = "FirewallEnabled")] bool FirewallEnabled, + [property: MessageBodyMember(Name = "InboundPinholeAllowed")] bool InboundPinholeAllowed); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV1.cs new file mode 100644 index 000000000..cef151497 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV1.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.GetNatRsipStatus, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +public readonly record struct GetNatRsipStatusRequestV1; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV2.cs new file mode 100644 index 000000000..5dc11e891 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusRequestV2.cs @@ -0,0 +1,6 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = UPnPConstants.GetNatRsipStatus, WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +public readonly record struct GetNatRsipStatusRequestV2; \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV1.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV1.cs new file mode 100644 index 000000000..5a7558a9d --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV1.cs @@ -0,0 +1,8 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.GetNatRsipStatus}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:1")] +public readonly record struct GetNatRsipStatusResponseV1( + [property: MessageBodyMember(Name = "NewRSIPAvailable")] bool RsipAvailable, + [property: MessageBodyMember(Name = "NewNATEnabled")] bool NatEnabled); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV2.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV2.cs new file mode 100644 index 000000000..8a00e70a3 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Actions/GetNatRsipStatusResponseV2.cs @@ -0,0 +1,8 @@ +using System.ServiceModel; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[MessageContract(WrapperName = $"{UPnPConstants.GetNatRsipStatus}Response", WrapperNamespace = $"{UPnPConstants.UPnPServiceNamespace}:{UPnPConstants.WanIpConnection}:2")] +public readonly record struct GetNatRsipStatusResponseV2( + [property: MessageBodyMember(Name = "NewRSIPAvailable")] bool RsipAvailable, + [property: MessageBodyMember(Name = "NewNATEnabled")] bool NatEnabled); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Device.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Device.cs new file mode 100644 index 000000000..c3757eb9e --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/Device.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "device", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct Device( + [property: DataMember(Name = "deviceType", Order = 0)] string DeviceType, + [property: DataMember(Name = "friendlyName", Order = 1)] string FriendlyName, + [property: DataMember(Name = "manufacturer", Order = 2)] string Manufacturer, + [property: DataMember(Name = "manufacturerURL", Order = 3)] string ManufacturerUrl, + [property: DataMember(Name = "modelDescription", Order = 4)] string ModelDescription, + [property: DataMember(Name = "modelName", Order = 5)] string ModelName, + [property: DataMember(Name = "modelNumber", Order = 6)] string ModelNumber, + [property: DataMember(Name = "modelURL", Order = 7)] string ModelUrl, + [property: DataMember(Name = "UDN", Order = 8)] string UniqueDeviceName, + [property: DataMember(Name = "UPC", Order = 9)] string Upc, + [property: DataMember(Name = "iconList", Order = 10)] IconListItem[] IconList, + [property: DataMember(Name = "serviceList", Order = 11)] ServiceListItem[] ServiceList, + [property: DataMember(Name = "deviceList", Order = 12)] Device[] DeviceList, + [property: DataMember(Name = "presentationURL", Order = 13)] string PresentationUrl); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/IconListItem.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/IconListItem.cs new file mode 100644 index 000000000..3cbd9146a --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/IconListItem.cs @@ -0,0 +1,11 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "icon", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct IconListItem( + [property: DataMember(Name = "mimetype", Order = 0)] string Mimetype, + [property: DataMember(Name = "width", Order = 1)] int Width, + [property: DataMember(Name = "height", Order = 2)] int Height, + [property: DataMember(Name = "depth", Order = 3)] int Depth, + [property: DataMember(Name = "url", Order = 4)] string Url); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/InternetGatewayDeviceResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/InternetGatewayDeviceResponse.cs new file mode 100644 index 000000000..e1701ea98 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/InternetGatewayDeviceResponse.cs @@ -0,0 +1,6 @@ +using System; +using System.Net; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +internal readonly record struct InternetGatewayDeviceResponse(Uri Location, string Server, string Usn, IPAddress LocalIpAddress); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/ServiceListItem.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/ServiceListItem.cs new file mode 100644 index 000000000..56c59502b --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/ServiceListItem.cs @@ -0,0 +1,11 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "service", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct ServiceListItem( + [property: DataMember(Name = "serviceType", Order = 0)] string ServiceType, + [property: DataMember(Name = "serviceId", Order = 1)] string ServiceId, + [property: DataMember(Name = "controlURL", Order = 2)] string ControlUrl, + [property: DataMember(Name = "eventSubURL", Order = 3)] string EventSubUrl, + [property: DataMember(Name = "SCPDURL", Order = 4)] string ScpdUrl); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SpecVersion.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SpecVersion.cs new file mode 100644 index 000000000..53d87c6b9 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SpecVersion.cs @@ -0,0 +1,8 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "specVersion", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct SpecVersion( + [property: DataMember(Name = "major", Order = 0)] int Major, + [property: DataMember(Name = "minor", Order = 1)] int Minor); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SystemVersion.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SystemVersion.cs new file mode 100644 index 000000000..df108aa82 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/SystemVersion.cs @@ -0,0 +1,12 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "systemVersion", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct SystemVersion( + [property: DataMember(Name = "HW", Order = 0)] int Hw, + [property: DataMember(Name = "Major", Order = 1)] int Major, + [property: DataMember(Name = "Minor", Order = 2)] int Minor, + [property: DataMember(Name = "Patch", Order = 3)] int Patch, + [property: DataMember(Name = "Buildnumber", Order = 4)] int BuildNumber, + [property: DataMember(Name = "Display", Order = 5)] string Display); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/UPnPDescription.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/UPnPDescription.cs new file mode 100644 index 000000000..476027945 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/Models/UPnPDescription.cs @@ -0,0 +1,9 @@ +using System.Runtime.Serialization; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +[DataContract(Name = "root", Namespace = UPnPConstants.UPnPDevice10Namespace)] +internal readonly record struct UPnPDescription( + [property: DataMember(Name = "specVersion", Order = 0)] SpecVersion SpecVersion, + [property: DataMember(Name = "systemVersion", Order = 1)] SystemVersion SystemVersion, + [property: DataMember(Name = "device", Order = 2)] Device Device); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPConstants.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPConstants.cs new file mode 100644 index 000000000..bc7ab953d --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPConstants.cs @@ -0,0 +1,24 @@ +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +internal static class UPnPConstants +{ + private const string UPnPNamespace = "urn:schemas-upnp-org"; + private const string UPnPDeviceNamespace = $"{UPnPNamespace}:device"; + public const string UPnPDevice10Namespace = $"{UPnPDeviceNamespace}-1-0"; + public const string UPnPServiceNamespace = $"{UPnPNamespace}:service"; + public const string UPnPWanConnectionDevice = $"{UPnPDeviceNamespace}:WANConnectionDevice"; + public const string UPnPWanDevice = $"{UPnPDeviceNamespace}:WANDevice"; + public const string UPnPInternetGatewayDevice = $"{UPnPDeviceNamespace}:InternetGatewayDevice"; + public const string WanIpConnection = "WANIPConnection"; + public const string WanIpv6FirewallControl = "WANIPv6FirewallControl"; + public const string UPnPRootDevice = "upnp:rootdevice"; + public const string AddAnyPortMapping = "AddAnyPortMapping"; + public const string AddPinhole = "AddPinhole"; + public const string AddPortMapping = "AddPortMapping"; + public const string DeletePinhole = "DeletePinhole"; + public const string DeletePortMapping = "DeletePortMapping"; + public const string GetExternalIPAddress = "GetExternalIPAddress"; + public const string GetFirewallStatus = "GetFirewallStatus"; + public const string GetNatRsipStatus = "GetNatRsipStatus"; + public const int UPnPMultiCastPort = 1900; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPHandler.cs b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPHandler.cs new file mode 100644 index 000000000..18394ca50 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/UPNP/UPnPHandler.cs @@ -0,0 +1,442 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; +using ClientCore; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet.UPNP; + +internal static class UPnPHandler +{ + private const int ReceiveTimeoutInSeconds = 2; + + public static readonly HttpClient HttpClient = new( + new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = async (context, token) => + { + Socket socket = null; + + try + { + socket = new(SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + + if (IPAddress.Parse(context.DnsEndPoint.Host).AddressFamily is AddressFamily.InterNetworkV6) + { + socket.Bind( + new IPEndPoint(NetworkHelper.GetLocalPublicIpV6Address() + ?? NetworkHelper.GetPrivateIpAddresses().First(q => q.AddressFamily is AddressFamily.InterNetworkV6), + 0)); + } + + await socket.ConnectAsync(context.DnsEndPoint, token).ConfigureAwait(false); + + return new NetworkStream(socket, true); + } + catch + { + socket?.Dispose(); + + throw; + } + }, + SslOptions = new() + { + RemoteCertificateValidationCallback = (_, _, _, sslPolicyErrors) => (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) == 0, + CertificateChainPolicy = new() + { + DisableCertificateDownloads = true + } + } + }, + true) + { + Timeout = TimeSpan.FromSeconds(ReceiveTimeoutInSeconds), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher + }; + + public static async ValueTask<( + InternetGatewayDevice InternetGatewayDevice, + List<(ushort InternalPort, ushort ExternalPort)> IpV6P2PPorts, + List<(ushort InternalPort, ushort ExternalPort)> IpV4P2PPorts, + List P2PIpV6PortIds, + IPAddress IpV6Address, + IPAddress IpV4Address)> SetupPortsAsync( + InternetGatewayDevice internetGatewayDevice, + List p2pReservedPorts, + List stunServerIpAddresses, + CancellationToken cancellationToken) + { + Logger.Log("P2P: Starting Setup."); + + internetGatewayDevice ??= await GetInternetGatewayDeviceAsync(cancellationToken).ConfigureAwait(false); + + Task<(IPAddress IpAddress, List<(ushort InternalPort, ushort ExternalPort)> Ports)> ipV4Task = + SetupIpV4PortsAsync(internetGatewayDevice, p2pReservedPorts, stunServerIpAddresses, cancellationToken); + Task<(IPAddress IpAddress, List<(ushort InternalPort, ushort ExternalPort)> Ports, List PortIds)> ipV6Task = + SetupIpV6PortsAsync(internetGatewayDevice, p2pReservedPorts, stunServerIpAddresses, cancellationToken); + + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(new Task[] { ipV4Task, ipV6Task }).ConfigureAwait(false); + + (IPAddress publicIpV4Address, List<(ushort InternalPort, ushort ExternalPort)> ipV4P2PPorts) = await ipV4Task.ConfigureAwait(false); + (IPAddress publicIpV6Address, List<(ushort InternalPort, ushort ExternalPort)> ipV6P2PPorts, List ipV6P2PPortIds) = await ipV6Task.ConfigureAwait(false); + + return (internetGatewayDevice, ipV6P2PPorts, ipV4P2PPorts, ipV6P2PPortIds, publicIpV6Address, publicIpV4Address); + } + + private static async Task GetInternetGatewayDeviceAsync(CancellationToken cancellationToken) + { + var internetGatewayDevices = (await GetInternetGatewayDevices(cancellationToken).ConfigureAwait(false)).ToList(); + + foreach (InternetGatewayDevice internetGatewayDevice in internetGatewayDevices) + { + Logger.Log($"P2P: Found gateway device v{internetGatewayDevice.UPnPDescription.Device.DeviceType.Split(':').LastOrDefault()} " + + $"{internetGatewayDevice.UPnPDescription.Device.FriendlyName} ({internetGatewayDevice.Server})."); + } + + InternetGatewayDevice selectedInternetGatewayDevice = GetInternetGatewayDeviceByVersion(internetGatewayDevices, 2); + + selectedInternetGatewayDevice ??= GetInternetGatewayDeviceByVersion(internetGatewayDevices, 1); + + if (selectedInternetGatewayDevice is not null) + { + Logger.Log($"P2P: Selected gateway device v{selectedInternetGatewayDevice.UPnPDescription.Device.DeviceType.Split(':').LastOrDefault()} " + + $"{selectedInternetGatewayDevice.UPnPDescription.Device.FriendlyName} ({selectedInternetGatewayDevice.Server})."); + } + else + { + Logger.Log("P2P: No gateway devices detected."); + } + + return selectedInternetGatewayDevice; + } + + private static async Task<(IPAddress IpAddress, List<(ushort InternalPort, ushort ExternalPort)> Ports, List PortIds)> SetupIpV6PortsAsync( + InternetGatewayDevice internetGatewayDevice, List p2pReservedPorts, List stunServerIpAddresses, CancellationToken cancellationToken) + { + (IPAddress stunPublicIpV6Address, List<(ushort InternalPort, ushort ExternalPort)> ipV6StunPortMapping) = await NetworkHelper.PerformStunAsync( + stunServerIpAddresses, p2pReservedPorts, AddressFamily.InterNetworkV6, cancellationToken).ConfigureAwait(false); + IPAddress localPublicIpV6Address = NetworkHelper.GetLocalPublicIpV6Address(); + var ipV6P2PPorts = new List<(ushort InternalPort, ushort ExternalPort)>(); + var ipV6P2PPortIds = new List(); + IPAddress publicIpV6Address = null; + + if (stunPublicIpV6Address is not null || localPublicIpV6Address is not null) + { + Logger.Log("P2P: Public IPV6 detected."); + + if (internetGatewayDevice is not null) + { + try + { + (bool? firewallEnabled, bool? inboundPinholeAllowed) = await internetGatewayDevice.GetIpV6FirewallStatusAsync( + cancellationToken).ConfigureAwait(false); + + if (firewallEnabled is not false && inboundPinholeAllowed is not false) + { + Logger.Log("P2P: Configuring IPV6 firewall."); + + ipV6P2PPortIds = (await ClientCore.Extensions.TaskExtensions.WhenAllSafe(p2pReservedPorts.Select( + q => internetGatewayDevice.OpenIpV6PortAsync(localPublicIpV6Address, q, cancellationToken))).ConfigureAwait(false)).ToList(); + } + } + catch (Exception ex) + { +#if DEBUG + ProgramConstants.LogException(ex, $"P2P: Could not open P2P IPV6 router ports for {localPublicIpV6Address}."); +#else + ProgramConstants.LogException(ex, $"P2P: Could not open P2P IPV6 router ports."); +#endif + } + } + + if (stunPublicIpV6Address is not null && localPublicIpV6Address is not null && !stunPublicIpV6Address.Equals(localPublicIpV6Address)) + { + publicIpV6Address = stunPublicIpV6Address; + ipV6P2PPorts = ipV6StunPortMapping.Any() ? ipV6StunPortMapping : p2pReservedPorts.Select(q => (q, q)).ToList(); + } + else + { + publicIpV6Address = stunPublicIpV6Address ?? localPublicIpV6Address; + ipV6P2PPorts = p2pReservedPorts.Select(q => (q, q)).ToList(); + } + } + + return (publicIpV6Address, ipV6P2PPorts, ipV6P2PPortIds); + } + + private static async Task<(IPAddress IpAddress, List<(ushort InternalPort, ushort ExternalPort)> Ports)> SetupIpV4PortsAsync( + InternetGatewayDevice internetGatewayDevice, List p2pReservedPorts, List stunServerIpAddresses, CancellationToken cancellationToken) + { + bool? routerNatEnabled = null; + IPAddress routerPublicIpV4Address = null; + + if (internetGatewayDevice is not null) + { + Task natRsipStatusTask = internetGatewayDevice.GetNatRsipStatusAsync(cancellationToken); + Task externalIpv4AddressTask = internetGatewayDevice.GetExternalIpV4AddressAsync(cancellationToken); + + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(new Task[] { natRsipStatusTask, externalIpv4AddressTask }).ConfigureAwait(false); + + routerNatEnabled = await natRsipStatusTask.ConfigureAwait(false); + routerPublicIpV4Address = await externalIpv4AddressTask.ConfigureAwait(false); + } + + (IPAddress stunPublicIpV4Address, List<(ushort InternalPort, ushort ExternalPort)> ipV4StunPortMapping) = await NetworkHelper.PerformStunAsync( + stunServerIpAddresses, p2pReservedPorts, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false); + IPAddress tracePublicIpV4Address = null; + + if (routerPublicIpV4Address is null && stunPublicIpV4Address is null) + { + Logger.Log("P2P: Using IPV4 trace detection."); + + tracePublicIpV4Address = await NetworkHelper.TracePublicIpV4Address(cancellationToken).ConfigureAwait(false); + } + + IPAddress localPublicIpV4Address = null; + + if (routerPublicIpV4Address is null && stunPublicIpV4Address is null && tracePublicIpV4Address is null) + { + Logger.Log("P2P: Using IPV4 local public address."); + + var localPublicIpAddresses = NetworkHelper.GetPublicIpAddresses().ToList(); + + localPublicIpV4Address = localPublicIpAddresses.FirstOrDefault(q => q.AddressFamily is AddressFamily.InterNetwork); + } + + IPAddress publicIpV4Address = stunPublicIpV4Address ?? routerPublicIpV4Address ?? tracePublicIpV4Address ?? localPublicIpV4Address; + var ipV4P2PPorts = new List<(ushort InternalPort, ushort ExternalPort)>(); + + if (publicIpV4Address is not null) + { + Logger.Log("P2P: Public IPV4 detected."); + + var privateIpV4Addresses = NetworkHelper.GetPrivateIpAddresses().Where(q => q.AddressFamily is AddressFamily.InterNetwork).ToList(); + IPAddress privateIpV4Address = privateIpV4Addresses.FirstOrDefault(); + + if (internetGatewayDevice is not null && privateIpV4Address is not null && routerNatEnabled is not false) + { + Logger.Log("P2P: Using IPV4 port mapping."); + + try + { + ipV4P2PPorts = (await ClientCore.Extensions.TaskExtensions.WhenAllSafe(p2pReservedPorts.Select( + q => internetGatewayDevice.OpenIpV4PortAsync(privateIpV4Address, q, cancellationToken))).ConfigureAwait(false)).Select(q => (q, q)).ToList(); + p2pReservedPorts = ipV4P2PPorts.Select(q => q.InternalPort).ToList(); + } + catch (Exception ex) + { +#if DEBUG + ProgramConstants.LogException(ex, $"P2P: Could not open P2P IPV4 router ports for {privateIpV4Address} -> {publicIpV4Address}."); +#else + ProgramConstants.LogException(ex, $"P2P: Could not open P2P IPV4 router ports."); +#endif + ipV4P2PPorts = ipV4StunPortMapping.Any() ? ipV4StunPortMapping : p2pReservedPorts.Select(q => (q, q)).ToList(); + } + } + else + { + ipV4P2PPorts = ipV4StunPortMapping.Any() ? ipV4StunPortMapping : p2pReservedPorts.Select(q => (q, q)).ToList(); + } + } + + return (publicIpV4Address, ipV4P2PPorts); + } + + private static async ValueTask> GetInternetGatewayDevices(CancellationToken cancellationToken) + { + IEnumerable devices = await GetDevicesAsync(cancellationToken).ConfigureAwait(false); + + return devices.Where(q => q?.UPnPDescription.Device.DeviceType?.StartsWith($"{UPnPConstants.UPnPInternetGatewayDevice}:", StringComparison.OrdinalIgnoreCase) ?? false); + } + + private static InternetGatewayDevice GetInternetGatewayDeviceByVersion(List internetGatewayDevices, ushort uPnPVersion) + => internetGatewayDevices.FirstOrDefault(q => $"{UPnPConstants.UPnPInternetGatewayDevice}:{uPnPVersion}".Equals(q.UPnPDescription.Device.DeviceType, StringComparison.OrdinalIgnoreCase)); + + private static async ValueTask> GetDevicesAsync(CancellationToken cancellationToken) + { + IEnumerable<(IPAddress LocalIpAddress, IEnumerable Responses)> rawDeviceResponses = await DetectDevicesAsync(cancellationToken).ConfigureAwait(false); + IEnumerable<(IPAddress LocalIpAddress, IEnumerable> Responses)> formattedDeviceResponses = + rawDeviceResponses.Select(q => (q.LocalIpAddress, GetFormattedDeviceResponses(q.Responses))); + IEnumerable> groupedInternetGatewayDeviceResponses = + GetGroupedDeviceResponses(formattedDeviceResponses); + + return await ClientCore.Extensions.TaskExtensions.WhenAllSafe( + groupedInternetGatewayDeviceResponses.Select(q => ParseDeviceAsync(q, cancellationToken))).ConfigureAwait(false); + } + + private static IEnumerable> GetGroupedDeviceResponses( + IEnumerable<(IPAddress LocalIpAddress, IEnumerable> Responses)> formattedDeviceResponses) + => formattedDeviceResponses + .SelectMany(q => q.Responses.Where(r => Guid.TryParse(r["LOCATION"], out _)).Select(r => new InternetGatewayDeviceResponse(new(r["LOCATION"]), r["SERVER"], r["USN"], q.LocalIpAddress))) + .GroupBy(q => q.Usn); + + private static Uri GetPreferredLocation(IReadOnlyCollection locations) + { + return locations.FirstOrDefault(q => q.HostNameType is UriHostNameType.IPv6 && !NetworkHelper.IsPrivateIpAddress(IPAddress.Parse(q.IdnHost))) + ?? locations.FirstOrDefault(q => q.HostNameType is UriHostNameType.IPv6 && NetworkHelper.IsPrivateIpAddress(IPAddress.Parse(q.IdnHost))) + ?? locations.First(q => q.HostNameType is UriHostNameType.IPv4); + } + + private static IEnumerable> GetFormattedDeviceResponses(IEnumerable responses) + { + return responses.Select(q => q.Split(Environment.NewLine)).Select(q => q.Where(r => r.Contains(':', StringComparison.OrdinalIgnoreCase)).ToDictionary( + s => s[..s.IndexOf(':', StringComparison.OrdinalIgnoreCase)], + s => + { + string value = s[s.IndexOf(':', StringComparison.OrdinalIgnoreCase)..]; + + if (value.EndsWith(":", StringComparison.OrdinalIgnoreCase)) + return value.Replace(":", null, StringComparison.OrdinalIgnoreCase); + + return value.Replace(": ", null, StringComparison.OrdinalIgnoreCase); + }, + StringComparer.OrdinalIgnoreCase)); + } + + private static async Task<(IPAddress LocalIpAddress, IEnumerable Responses)> SearchDevicesAsync(IPAddress localAddress, IPAddress multicastAddress, CancellationToken cancellationToken) + { + var responses = new List(); + using var socket = new Socket(SocketType.Dgram, ProtocolType.Udp); + var localEndPoint = new IPEndPoint(localAddress, 0); + var multiCastIpEndPoint = new IPEndPoint(multicastAddress, UPnPConstants.UPnPMultiCastPort); + + try + { + socket.Bind(localEndPoint); + + string request = FormattableString.Invariant($"M-SEARCH * HTTP/1.1\r\nHOST: {NetworkHelper.FormatUri(multiCastIpEndPoint).Authority}\r\nST: {UPnPConstants.UPnPRootDevice}\r\nMAN: \"ssdp:discover\"\r\nMX: {ReceiveTimeoutInSeconds}\r\n\r\n"); + const int charSize = sizeof(char); + int bufferSize = request.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int numberOfBytes = Encoding.UTF8.GetBytes(request.AsSpan(), buffer.Span); + + buffer = buffer[..numberOfBytes]; + + await socket.SendToAsync(buffer, SocketFlags.None, multiCastIpEndPoint, cancellationToken).ConfigureAwait(false); + await ReceiveAsync(socket, responses, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, $"P2P: Could not detect UPnP devices on {localEndPoint} / {multiCastIpEndPoint}."); + } + + return new(localAddress, responses); + } + + private static async ValueTask ReceiveAsync(Socket socket, ICollection responses, CancellationToken cancellationToken) + { + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(4096); + using var timeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(ReceiveTimeoutInSeconds)); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken); + + while (!linkedCancellationTokenSource.IsCancellationRequested) + { + Memory buffer = memoryOwner.Memory[..4096]; + + try + { + int bytesReceived = await socket.ReceiveAsync(buffer, SocketFlags.None, linkedCancellationTokenSource.Token).ConfigureAwait(false); + + responses.Add(Encoding.UTF8.GetString(buffer.Span[..bytesReceived])); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + } + + private static async ValueTask GetDescriptionAsync(Uri uri, CancellationToken cancellationToken) + { + Stream uPnPDescription = await HttpClient.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + + await using (uPnPDescription.ConfigureAwait(false)) + { + using var xmlTextReader = new XmlTextReader(uPnPDescription); + + return (UPnPDescription)new DataContractSerializer(typeof(UPnPDescription)).ReadObject(xmlTextReader); + } + } + + private static async ValueTask Responses)>> DetectDevicesAsync(CancellationToken cancellationToken) + { + IEnumerable unicastAddresses = NetworkHelper.GetLocalAddresses(); + IEnumerable multicastAddresses = NetworkHelper.GetMulticastAddresses(); + (IPAddress LocalIpAddress, IEnumerable Responses)[] localAddressesDeviceResponses = await ClientCore.Extensions.TaskExtensions.WhenAllSafe( + multicastAddresses.SelectMany(q => unicastAddresses.Where(r => r.AddressFamily == q.AddressFamily).Select(r => SearchDevicesAsync(r, q, cancellationToken)))).ConfigureAwait(false); + + return localAddressesDeviceResponses.Where(q => q.Responses.Any(r => r.Any())).Select(q => (q.LocalIpAddress, q.Responses)).Distinct(); + } + + private static async Task ParseDeviceAsync( + IGrouping internetGatewayDeviceResponses, CancellationToken cancellationToken) + { + Uri[] locations = null; + + try + { + locations = internetGatewayDeviceResponses.Select(q => (q.LocalIpAddress, q.Location)).Distinct().Select(ParseLocation).ToArray(); + + Uri preferredLocation = GetPreferredLocation(locations); + UPnPDescription uPnPDescription = default; + + try + { + uPnPDescription = await GetDescriptionAsync(preferredLocation, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (preferredLocation.HostNameType is UriHostNameType.IPv6 && locations.Any(q => q.HostNameType is UriHostNameType.IPv4)) + { + try + { + preferredLocation = locations.First(q => q.HostNameType is UriHostNameType.IPv4); + uPnPDescription = await GetDescriptionAsync(preferredLocation, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + } + + return new( + locations, + internetGatewayDeviceResponses.Select(r => r.Server).Distinct().Single(), + uPnPDescription, + preferredLocation); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, $"P2P: Could not get UPnP description from {locations?.Select(q => q.ToString()).DefaultIfEmpty().Aggregate((q, r) => $"{q} / {r}")}."); + + return null; + } + } + + private static Uri ParseLocation((IPAddress LocalIpAddress, Uri Location) location) + { + if (location.Location.HostNameType is not UriHostNameType.IPv6 || !IPAddress.TryParse(location.Location.IdnHost, out IPAddress ipAddress) || !NetworkHelper.IsPrivateIpAddress(ipAddress)) + return location.Location; + + return NetworkHelper.FormatUri(new(IPAddress.Parse(FormattableString.Invariant($"{location.Location.IdnHost}%{location.LocalIpAddress.ScopeId}")), location.Location.Port), location.Location.Scheme, location.Location.PathAndQuery); + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/V3ConnectionState.cs b/DXMainClient/Domain/Multiplayer/CnCNet/V3ConnectionState.cs new file mode 100644 index 000000000..0e133eba3 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/V3ConnectionState.cs @@ -0,0 +1,464 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using DTAClient.Domain.Multiplayer.CnCNet.Replays; +using DTAClient.Domain.Multiplayer.CnCNet.UPNP; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +internal sealed class V3ConnectionState : IAsyncDisposable +{ + private const ushort MAX_REMOTE_PLAYERS = 7; + private const int PINNED_DYNAMIC_TUNNELS = 15; + + private readonly TunnelHandler tunnelHandler; + private readonly List<(string RemotePlayerName, CnCNetTunnel Tunnel, int CombinedPing)> playerTunnels = new(); + private readonly Dictionary playerP2PRequestMessages = new(); + private readonly ReplayHandler replayHandler = new(); + + private IPAddress publicIpV4Address; + private IPAddress publicIpV6Address; + private List p2pIpV6PortIds = new(); + private InternetGatewayDevice internetGatewayDevice; + private List playerInfos; + private List gamePlayerIds; + private List<(ushort InternalPort, ushort ExternalPort)> ipV6P2PPorts = new(); + private List<(ushort InternalPort, ushort ExternalPort)> ipV4P2PPorts = new(); + + public List<(int Ping, string Hash)> PinnedTunnels { get; private set; } = new(); + + public string PinnedTunnelPingsMessage { get; private set; } + + public bool DynamicTunnelsEnabled { get; set; } + + public bool P2PEnabled { get; set; } + + public bool RecordingEnabled { get; set; } + + public CnCNetTunnel InitialTunnel { get; private set; } + + public CancellationTokenSource StunCancellationTokenSource { get; private set; } + + public List<(List RemotePlayerNames, V3GameTunnelHandler Tunnel)> V3GameTunnelHandlers { get; } = new(); + + public List P2PPlayers { get; } = new(); + + public V3ConnectionState(TunnelHandler tunnelHandler) + { + this.tunnelHandler = tunnelHandler; + } + + public void Setup(CnCNetTunnel tunnel) + { + InitialTunnel = tunnel; + tunnelHandler.CurrentTunnel = !DynamicTunnelsEnabled ? InitialTunnel : GetEligibleTunnels().MinBy(q => q.PingInMs); + } + + public void PinTunnels() + { + PinnedTunnels = GetEligibleTunnels() + .OrderBy(q => q.PingInMs) + .ThenBy(q => q.Hash, StringComparer.OrdinalIgnoreCase) + .Take(PINNED_DYNAMIC_TUNNELS) + .Select(q => (q.PingInMs, q.Hash)) + .ToList(); + + IEnumerable tunnelPings = PinnedTunnels + .Select(q => FormattableString.Invariant($"{q.Ping};{q.Hash}\t")); + + PinnedTunnelPingsMessage = string.Concat(tunnelPings); + } + + public async ValueTask HandlePlayerP2PRequestAsync() + { + if (!ipV6P2PPorts.Any() && !ipV4P2PPorts.Any()) + { + StunCancellationTokenSource?.Cancel(); + StunCancellationTokenSource?.Dispose(); + + StunCancellationTokenSource = new(); + + var p2pPorts = NetworkHelper.GetFreeUdpPorts(Array.Empty(), MAX_REMOTE_PLAYERS).ToList(); + + try + { + (internetGatewayDevice, ipV6P2PPorts, ipV4P2PPorts, p2pIpV6PortIds, publicIpV6Address, publicIpV4Address) = await UPnPHandler.SetupPortsAsync( + internetGatewayDevice, p2pPorts, GetEligibleTunnels().OrderBy(q => q.PingInMs).SelectMany(q => q.IPAddresses).ToList(), StunCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + return publicIpV4Address is not null || publicIpV6Address is not null; + } + + public void RemoveV3Player(string playerName) + { + playerTunnels.Remove(playerTunnels.SingleOrDefault(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase))); + P2PPlayers.Remove(P2PPlayers.SingleOrDefault(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase))); + } + + public string GetP2PRequestCommand() + => $" {publicIpV4Address}\t{(!ipV4P2PPorts.Any() ? null : ipV4P2PPorts.Select(q => q.ExternalPort.ToString(CultureInfo.InvariantCulture)).DefaultIfEmpty().Aggregate((q, r) => $"{q}-{r}"))}" + + $";{publicIpV6Address}\t{(!ipV6P2PPorts.Any() ? null : ipV6P2PPorts.Select(q => q.ExternalPort.ToString(CultureInfo.InvariantCulture)).DefaultIfEmpty().Aggregate((q, r) => $"{q}-{r}"))}"; + + public string GetP2PPingCommand(string playerName) + => $" {playerName}-{P2PPlayers.Single(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase)).LocalPingResults.Select(q => $"{q.RemoteIpAddress};{q.Ping}\t").DefaultIfEmpty().Aggregate((q, r) => $"{q}{r}")}"; + + public async ValueTask ToggleP2PAsync() + { + P2PEnabled = !P2PEnabled; + + if (P2PEnabled) + return true; + + await CloseP2PPortsAsync().ConfigureAwait(false); + + internetGatewayDevice = null; + publicIpV4Address = null; + publicIpV6Address = null; + + return false; + } + + public async ValueTask ToggleRecordingAsync() + { + RecordingEnabled = !RecordingEnabled; + + if (RecordingEnabled) + return true; + + await replayHandler.DisposeAsync().ConfigureAwait(false); + + return false; + } + + public void StoreP2PRequest(string playerName, string p2pRequestMessage) + => playerP2PRequestMessages[playerName] = p2pRequestMessage; + + public string GetP2PRequest(string playerName) + => playerP2PRequestMessages.TryGetValue(playerName, out string p2pRequestMessage) ? p2pRequestMessage : null; + + public async ValueTask PingRemotePlayerAsync(string playerName, string p2pRequestMessage) + { + List<(IPAddress RemoteIpAddress, long Ping)> localPingResults = new(); + string[] splitLines = p2pRequestMessage.Split(';'); + string[] ipV4splitLines = splitLines[0].Split('\t'); + string[] ipV6splitLines = splitLines[1].Split('\t'); + Task<(IPAddress IpAddress, ushort[] Ports, long? Ping)> ipV4Task = PingP2PAddressAsync(ipV4splitLines, playerName); + Task<(IPAddress IpAddress, ushort[] Ports, long? Ping)> ipV6Task = PingP2PAddressAsync(ipV6splitLines, playerName); + + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(new Task[] { ipV4Task, ipV6Task }).ConfigureAwait(false); + + (IPAddress remoteIpV4Address, ushort[] remoteIpV4Ports, long? ipV4Ping) = await ipV4Task.ConfigureAwait(false); + (IPAddress remoteIpV6Address, ushort[] remoteIpV6Ports, long? ipV6Ping) = await ipV6Task.ConfigureAwait(false); + + if (ipV4Ping is not null) + localPingResults.Add(new(remoteIpV4Address, ipV4Ping.Value)); + + if (ipV6Ping is not null) + localPingResults.Add(new(remoteIpV6Address, ipV6Ping.Value)); + + P2PPlayer remoteP2PPlayer; + + if (P2PPlayers.Any(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase))) + { + remoteP2PPlayer = P2PPlayers.Single(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase)); + + P2PPlayers.Remove(remoteP2PPlayer); + } + else + { + remoteP2PPlayer = new(playerName, Array.Empty(), Array.Empty(), new(), new()); + } + + P2PPlayers.Add(remoteP2PPlayer with { LocalPingResults = localPingResults, RemoteIpV6Ports = remoteIpV6Ports, RemoteIpV4Ports = remoteIpV4Ports }); + + return localPingResults.Any(); + } + + public string UpdateRemotePingResults(string senderName, string p2pPingsMessage, string localPlayerName) + { + if (!P2PEnabled) + return null; + + string[] splitLines = p2pPingsMessage.Split('-'); + string pingPlayerName = splitLines[0]; + + if (!localPlayerName.Equals(pingPlayerName, StringComparison.OrdinalIgnoreCase)) + return null; + + string[] pingResults = splitLines[1].Split('\t', StringSplitOptions.RemoveEmptyEntries); + List<(IPAddress IpAddress, long Ping)> playerPings = new(); + + foreach (string pingResult in pingResults) + { + string[] ipAddressPingResult = pingResult.Split(';'); + + if (IPAddress.TryParse(ipAddressPingResult[0], out IPAddress ipV4Address)) + playerPings.Add((ipV4Address, long.Parse(ipAddressPingResult[1], CultureInfo.InvariantCulture))); + } + + P2PPlayer p2pPlayer; + + if (P2PPlayers.Any(q => q.RemotePlayerName.Equals(senderName, StringComparison.OrdinalIgnoreCase))) + { + p2pPlayer = P2PPlayers.Single(q => q.RemotePlayerName.Equals(senderName, StringComparison.OrdinalIgnoreCase)); + + P2PPlayers.Remove(p2pPlayer); + } + else + { + p2pPlayer = new(senderName, Array.Empty(), Array.Empty(), new(), new()); + } + + p2pPlayer = p2pPlayer with { RemotePingResults = playerPings }; + + P2PPlayers.Add(p2pPlayer); + + return !p2pPlayer.LocalPingResults.Any() ? GetP2PRequest(senderName) : null; + } + + public void StartV3ConnectionListeners( + int uniqueGameId, + uint gameLocalPlayerId, + string localPlayerName, + List playerInfos, + Action remoteHostConnectedAction, + Action remoteHostConnectionFailedAction, + CancellationToken cancellationToken = default) + { + this.playerInfos = playerInfos; + + V3GameTunnelHandlers.Clear(); + + if (RecordingEnabled) + replayHandler.SetupRecording(uniqueGameId, gameLocalPlayerId); + + if (!DynamicTunnelsEnabled) + { + SetupGameTunnelHandler( + gameLocalPlayerId, + remoteHostConnectedAction, + remoteHostConnectionFailedAction, + playerInfos.Where(q => !q.Name.Equals(localPlayerName, StringComparison.OrdinalIgnoreCase)).Select(q => q.Name).ToList(), + new(tunnelHandler.CurrentTunnel.IPAddress, tunnelHandler.CurrentTunnel.Port), + 0, + cancellationToken); + } + else + { + List p2pPlayerTunnels = new(); + + if (P2PEnabled) + { + foreach (var (remotePlayerName, remoteIpV6Ports, remoteIpV4Ports, localPingResults, remotePingResults) in P2PPlayers.Where(q => q.RemotePingResults.Any() && q.LocalPingResults.Any())) + { + (IPAddress selectedRemoteIpAddress, long combinedPing) = localPingResults + .Where(q => q.RemoteIpAddress is not null && remotePingResults + .Where(r => r.RemoteIpAddress is not null) + .Select(r => r.RemoteIpAddress.AddressFamily) + .Contains(q.RemoteIpAddress.AddressFamily)) + .Select(q => (q.RemoteIpAddress, q.Ping + remotePingResults.Single(r => r.RemoteIpAddress.AddressFamily == q.RemoteIpAddress.AddressFamily).Ping)) + .MaxBy(q => q.RemoteIpAddress.AddressFamily); + bool commonDynamicTunnel = playerTunnels.Any(q => q.RemotePlayerName.Equals(remotePlayerName, StringComparison.OrdinalIgnoreCase)); + + if (!commonDynamicTunnel || combinedPing < playerTunnels.Single(q => q.RemotePlayerName.Equals(remotePlayerName, StringComparison.OrdinalIgnoreCase)).CombinedPing) + { + ushort[] localPorts; + ushort[] remotePorts; + + if (selectedRemoteIpAddress.AddressFamily is AddressFamily.InterNetworkV6) + { + localPorts = ipV6P2PPorts.Select(q => q.InternalPort).ToArray(); + remotePorts = remoteIpV6Ports; + } + else + { + localPorts = ipV4P2PPorts.Select(q => q.InternalPort).ToArray(); + remotePorts = remoteIpV4Ports; + } + + var allPlayerNames = playerInfos.Select(q => q.Name).OrderBy(q => q, StringComparer.OrdinalIgnoreCase).ToList(); + var remotePlayerNames = allPlayerNames.Where(q => !q.Equals(localPlayerName, StringComparison.OrdinalIgnoreCase)).ToList(); + var tunnelClientPlayerNames = allPlayerNames.Where(q => !q.Equals(remotePlayerName, StringComparison.OrdinalIgnoreCase)).ToList(); + ushort localPort = localPorts[6 - remotePlayerNames.FindIndex(q => q.Equals(remotePlayerName, StringComparison.OrdinalIgnoreCase))]; + ushort remotePort = remotePorts[6 - tunnelClientPlayerNames.FindIndex(q => q.Equals(localPlayerName, StringComparison.OrdinalIgnoreCase))]; + + SetupGameTunnelHandler( + gameLocalPlayerId, + remoteHostConnectedAction, + remoteHostConnectionFailedAction, + new() { remotePlayerName }, + new(selectedRemoteIpAddress, remotePort), + localPort, + cancellationToken); + p2pPlayerTunnels.Add(remotePlayerName); + } + } + } + + foreach (IGrouping tunnelGrouping in playerTunnels.Where(q => !p2pPlayerTunnels.Contains(q.RemotePlayerName, StringComparer.OrdinalIgnoreCase)).GroupBy(q => q.Tunnel)) + { + SetupGameTunnelHandler( + gameLocalPlayerId, + remoteHostConnectedAction, + remoteHostConnectionFailedAction, + tunnelGrouping.Select(q => q.Name).ToList(), + new(tunnelGrouping.Key.IPAddress, tunnelGrouping.Key.Port), + 0, + cancellationToken); + } + } + } + + public List StartPlayerConnections(List gamePlayerIds) + { + this.gamePlayerIds = gamePlayerIds; + + List usedPorts = new(ipV4P2PPorts.Select(q => q.InternalPort).Concat(ipV6P2PPorts.Select(q => q.InternalPort)).Distinct()); + + foreach ((List remotePlayerNames, V3GameTunnelHandler v3GameTunnelHandler) in V3GameTunnelHandlers) + { + var currentTunnelPlayers = playerInfos.Where(q => remotePlayerNames.Contains(q.Name)).ToList(); + IEnumerable indexes = currentTunnelPlayers.Select(q => q.Index); + var playerIds = indexes.Select(q => gamePlayerIds[q]).ToList(); + var createdLocalPlayerPorts = v3GameTunnelHandler.CreatePlayerConnections(playerIds).ToList(); + int i = 0; + + foreach (PlayerInfo currentTunnelPlayer in currentTunnelPlayers) + currentTunnelPlayer.Port = createdLocalPlayerPorts.Skip(i++).Take(1).Single(); + + usedPorts.AddRange(createdLocalPlayerPorts); + } + + foreach (V3GameTunnelHandler v3GameTunnelHandler in V3GameTunnelHandlers.Select(q => q.Tunnel)) + v3GameTunnelHandler.StartPlayerConnections(); + + return usedPorts; + } + + public async ValueTask SaveReplayAsync() + { + if (!RecordingEnabled) + return; + + await replayHandler.StopRecordingAsync(gamePlayerIds, playerInfos, V3GameTunnelHandlers.Select(q => q.Tunnel).ToList()).ConfigureAwait(false); + } + + public async ValueTask ClearConnectionsAsync() + { + if (replayHandler is not null) + await replayHandler.DisposeAsync().ConfigureAwait(false); + + foreach (V3GameTunnelHandler v3GameTunnelHandler in V3GameTunnelHandlers.Select(q => q.Tunnel)) + v3GameTunnelHandler.Dispose(); + + V3GameTunnelHandlers.Clear(); + } + + public async ValueTask DisposeAsync() + { + PinnedTunnelPingsMessage = null; + StunCancellationTokenSource?.Cancel(); + await ClearConnectionsAsync().ConfigureAwait(false); + playerTunnels.Clear(); + P2PPlayers.Clear(); + PinnedTunnels?.Clear(); + playerP2PRequestMessages.Clear(); + await CloseP2PPortsAsync().ConfigureAwait(false); + } + + public IEnumerable GetEligibleTunnels() + => tunnelHandler.Tunnels.Where(q => !q.RequiresPassword && q.PingInMs > -1 && q.Clients < q.MaxClients - 8 && q.Version is Constants.TUNNEL_VERSION_3); + + public string HandleTunnelPingsMessage(string playerName, string tunnelPingsMessage) + { + string[] tunnelPingsLines = tunnelPingsMessage.Split('\t', StringSplitOptions.RemoveEmptyEntries); + IEnumerable<(int Ping, string Hash)> tunnelPings = tunnelPingsLines.Select(q => + { + string[] split = q.Split(';'); + + return (int.Parse(split[0], CultureInfo.InvariantCulture), split[1]); + }); + IEnumerable<(int CombinedPing, string Hash)> combinedTunnelResults = tunnelPings + .Where(q => PinnedTunnels.Select(r => r.Hash).Contains(q.Hash)) + .Select(q => (CombinedPing: q.Ping + PinnedTunnels.SingleOrDefault(r => q.Hash.Equals(r.Hash, StringComparison.OrdinalIgnoreCase)).Ping, q.Hash)); + (int combinedPing, string hash) = combinedTunnelResults + .OrderBy(q => q.CombinedPing) + .ThenBy(q => q.Hash, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (hash is null) + return null; + + CnCNetTunnel tunnel = tunnelHandler.Tunnels.Single(q => q.Hash.Equals(hash, StringComparison.OrdinalIgnoreCase)); + + playerTunnels.Remove(playerTunnels.SingleOrDefault(q => q.RemotePlayerName.Equals(playerName, StringComparison.OrdinalIgnoreCase))); + playerTunnels.Add(new(playerName, tunnel, combinedPing)); + + return hash; + } + + private static async Task<(IPAddress IpAddress, ushort[] Ports, long? Ping)> PingP2PAddressAsync(IReadOnlyList ipAddressInfo, string playerName) + { + if (!IPAddress.TryParse(ipAddressInfo[0], out IPAddress parsedIpAddress)) + return new(null, Array.Empty(), null); + + long? pingResult = await NetworkHelper.PingAsync(parsedIpAddress).ConfigureAwait(false); + + if (pingResult is null) + Logger.Log($"P2P: Could not ping {playerName} using {parsedIpAddress.AddressFamily}."); + + return new(parsedIpAddress, ipAddressInfo[1].Split('-').Select(q => ushort.Parse(q, CultureInfo.InvariantCulture)).ToArray(), pingResult); + } + + private void SetupGameTunnelHandler( + uint gameLocalPlayerId, + Action remoteHostConnectedAction, + Action remoteHostConnectionFailedAction, + List remotePlayerNames, + IPEndPoint remoteIpEndpoint, + ushort localPort, + CancellationToken cancellationToken) + { + var gameTunnelHandler = new V3GameTunnelHandler(); + + gameTunnelHandler.RaiseRemoteHostConnectedEvent += (_, _) => remoteHostConnectedAction(); + gameTunnelHandler.RaiseRemoteHostConnectionFailedEvent += (_, _) => remoteHostConnectionFailedAction(); + + if (RecordingEnabled) + { + gameTunnelHandler.RaiseRemoteHostDataReceivedEvent += replayHandler.RemoteHostConnection_DataReceivedAsync; + gameTunnelHandler.RaiseLocalGameDataReceivedEvent += replayHandler.LocalGameConnection_DataReceivedAsync; + } + + gameTunnelHandler.SetUp(remoteIpEndpoint, localPort, gameLocalPlayerId, cancellationToken); + gameTunnelHandler.ConnectToTunnel(); + V3GameTunnelHandlers.Add(new(remotePlayerNames, gameTunnelHandler)); + } + + private async ValueTask CloseP2PPortsAsync() + { + List tasks = new(); + + if (internetGatewayDevice is not null) + { + tasks.Add(ClientCore.Extensions.TaskExtensions.WhenAllSafe(ipV4P2PPorts.Select(q => internetGatewayDevice.CloseIpV4PortAsync(q.InternalPort, CancellationToken.None)))); + tasks.Add(ClientCore.Extensions.TaskExtensions.WhenAllSafe(p2pIpV6PortIds.Select(q => internetGatewayDevice.CloseIpV6PortAsync(q, CancellationToken.None)))); + } + + ipV4P2PPorts.Clear(); + ipV6P2PPorts.Clear(); + p2pIpV6PortIds.Clear(); + + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(tasks).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/V3GameTunnelHandler.cs b/DXMainClient/Domain/Multiplayer/CnCNet/V3GameTunnelHandler.cs new file mode 100644 index 000000000..5fcda578b --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/V3GameTunnelHandler.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using ClientCore.Extensions; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +/// +/// Manages connections between one or more s representing local game players and a representing a remote host. +/// +internal sealed class V3GameTunnelHandler : IDisposable +{ + private readonly Dictionary localGameConnections = new(); + private readonly CancellationTokenSource connectionErrorCancellationTokenSource = new(); + + private V3RemotePlayerConnection remoteHostConnection; + private EventHandler remoteHostConnectionDataReceivedFunc; + private EventHandler localGameConnectionDataReceivedFunc; + + /// + /// Occurs when the connection to the remote host succeeded. + /// + public event EventHandler RaiseRemoteHostConnectedEvent; + + /// + /// Occurs when the connection to the remote host could not be made. + /// + public event EventHandler RaiseRemoteHostConnectionFailedEvent; + + /// + /// Occurs when data from a remote host is received. + /// + public event EventHandler RaiseRemoteHostDataReceivedEvent; + + /// + /// Occurs when data from the local game is received. + /// + public event EventHandler RaiseLocalGameDataReceivedEvent; + + public bool ConnectSucceeded { get; private set; } + + public void SetUp(IPEndPoint remoteIpEndPoint, ushort localPort, uint gameLocalPlayerId, CancellationToken cancellationToken) + { + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + connectionErrorCancellationTokenSource.Token, cancellationToken); + + remoteHostConnection = new(); + remoteHostConnectionDataReceivedFunc = (sender, e) => RemoteHostConnection_DataReceivedAsync(sender, e).HandleTask(); + localGameConnectionDataReceivedFunc = (sender, e) => LocalGameConnection_DataReceivedAsync(sender, e).HandleTask(); + + remoteHostConnection.RaiseConnectedEvent += RemoteHostConnection_Connected; + remoteHostConnection.RaiseConnectionFailedEvent += RemoteHostConnection_ConnectionFailed; + remoteHostConnection.RaiseConnectionCutEvent += RemoteHostConnection_ConnectionCut; + remoteHostConnection.RaiseDataReceivedEvent += remoteHostConnectionDataReceivedFunc; + + remoteHostConnection.SetUp(remoteIpEndPoint, localPort, gameLocalPlayerId, cancellationToken); + } + + public IEnumerable CreatePlayerConnections(List playerIds) + { + foreach (uint playerId in playerIds) + { + var localPlayerConnection = new V3LocalPlayerConnection(); + + localPlayerConnection.RaiseConnectionCutEvent += LocalGameConnection_ConnectionCut; + localPlayerConnection.RaiseDataReceivedEvent += localGameConnectionDataReceivedFunc; + + localGameConnections.Add(playerId, localPlayerConnection); + + yield return localPlayerConnection.Setup(playerId, connectionErrorCancellationTokenSource.Token); + } + } + + public void StartPlayerConnections() + { + foreach (KeyValuePair playerConnection in localGameConnections) + playerConnection.Value.StartConnectionAsync().HandleTask(); + } + + public void ConnectToTunnel() + => remoteHostConnection.StartConnectionAsync().HandleTask(); + + public void Dispose() + { + if (!connectionErrorCancellationTokenSource.IsCancellationRequested) + connectionErrorCancellationTokenSource.Cancel(); + + connectionErrorCancellationTokenSource.Dispose(); + + foreach (KeyValuePair localGamePlayerConnection in localGameConnections) + { + localGamePlayerConnection.Value.RaiseConnectionCutEvent -= LocalGameConnection_ConnectionCut; + localGamePlayerConnection.Value.RaiseDataReceivedEvent -= localGameConnectionDataReceivedFunc; + + localGamePlayerConnection.Value.Dispose(); + } + + localGameConnections.Clear(); + + if (remoteHostConnection == null) + return; + + remoteHostConnection.RaiseConnectedEvent -= RemoteHostConnection_Connected; + remoteHostConnection.RaiseConnectionFailedEvent -= RemoteHostConnection_ConnectionFailed; + remoteHostConnection.RaiseConnectionCutEvent -= RemoteHostConnection_ConnectionCut; + remoteHostConnection.RaiseDataReceivedEvent -= remoteHostConnectionDataReceivedFunc; + + remoteHostConnection.Dispose(); + } + + private void LocalGameConnection_ConnectionCut(object sender, EventArgs e) + { + var localGamePlayerConnection = sender as V3LocalPlayerConnection; + + localGameConnections.Remove(localGameConnections.Single(q => q.Value == localGamePlayerConnection).Key); + + localGamePlayerConnection.RaiseConnectionCutEvent -= LocalGameConnection_ConnectionCut; + localGamePlayerConnection.RaiseDataReceivedEvent -= localGameConnectionDataReceivedFunc; + localGamePlayerConnection.Dispose(); + + if (!localGameConnections.Any()) + Dispose(); + } + + /// + /// Forwards local game data to the remote host. + /// + private async ValueTask LocalGameConnection_DataReceivedAsync(object sender, DataReceivedEventArgs e) + { + OnRaiseLocalGameDataReceivedEvent(sender, e); + + if (remoteHostConnection is not null) + await remoteHostConnection.SendDataToRemotePlayerAsync(e.GameData, e.PlayerId).ConfigureAwait(false); + } + + /// + /// Forwards remote player data to the local game. + /// + private async ValueTask RemoteHostConnection_DataReceivedAsync(object sender, DataReceivedEventArgs e) + { + OnRaiseRemoteHostDataReceivedEvent(sender, e); + + V3LocalPlayerConnection v3LocalPlayerConnection = GetLocalPlayerConnection(e.PlayerId); + + if (v3LocalPlayerConnection is not null) + await v3LocalPlayerConnection.SendDataToGameAsync(e.GameData).ConfigureAwait(false); + } + + private V3LocalPlayerConnection GetLocalPlayerConnection(uint senderId) + => localGameConnections.TryGetValue(senderId, out V3LocalPlayerConnection connection) ? connection : null; + + private void RemoteHostConnection_Connected(object sender, EventArgs e) + { + ConnectSucceeded = true; + + OnRaiseRemoteHostConnectedEvent(EventArgs.Empty); + } + + private void RemoteHostConnection_ConnectionFailed(object sender, EventArgs e) + => OnRaiseRemoteHostConnectionFailedEvent(EventArgs.Empty); + + private void OnRaiseRemoteHostConnectedEvent(EventArgs e) + { + EventHandler raiseEvent = RaiseRemoteHostConnectedEvent; + + raiseEvent?.Invoke(this, e); + } + + private void OnRaiseRemoteHostConnectionFailedEvent(EventArgs e) + { + EventHandler raiseEvent = RaiseRemoteHostConnectionFailedEvent; + + raiseEvent?.Invoke(this, e); + } + + private void RemoteHostConnection_ConnectionCut(object sender, EventArgs e) + => Dispose(); + + private void OnRaiseRemoteHostDataReceivedEvent(object sender, DataReceivedEventArgs e) + { + EventHandler raiseEvent = RaiseRemoteHostDataReceivedEvent; + + raiseEvent?.Invoke(sender, e); + } + + private void OnRaiseLocalGameDataReceivedEvent(object sender, DataReceivedEventArgs e) + { + EventHandler raiseEvent = RaiseLocalGameDataReceivedEvent; + + raiseEvent?.Invoke(sender, e); + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/V3LocalPlayerConnection.cs b/DXMainClient/Domain/Multiplayer/CnCNet/V3LocalPlayerConnection.cs new file mode 100644 index 000000000..29c8329b0 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/V3LocalPlayerConnection.cs @@ -0,0 +1,68 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +#if DEBUG +using Rampastring.Tools; +#endif + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +/// +/// Manages a player connection between the local game and this application. +/// +internal sealed class V3LocalPlayerConnection : PlayerConnection +{ + private const uint IOC_IN = 0x80000000; + private const uint IOC_VENDOR = 0x18000000; + private const uint SIO_UDP_CONNRESET = IOC_IN | IOC_VENDOR | 12; + + private readonly IPEndPoint loopbackIpEndPoint = new(IPAddress.Loopback, 0); + + /// + /// Creates a local game socket and returns the port. + /// + /// The id of the player for which to create the local game socket. + /// The to stop the connection. + /// The port of the created socket. + public ushort Setup(uint playerId, CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + PlayerId = playerId; + Socket = new(SocketType.Dgram, ProtocolType.Udp); + RemoteEndPoint = loopbackIpEndPoint; + + // Disable ICMP port not reachable exceptions, happens when the game is still loading and has not yet opened the socket. + if (OperatingSystem.IsWindows()) + Socket.IOControl(unchecked((int)SIO_UDP_CONNRESET), new byte[] { 0 }, null); + + Socket.Bind(loopbackIpEndPoint); + + return (ushort)((IPEndPoint)Socket.LocalEndPoint).Port; + } + + /// + /// Sends remote player data to the local game. + /// + /// The data to send to the game. + public async ValueTask SendDataToGameAsync(ReadOnlyMemory data) + { + if (RemoteEndPoint.Equals(loopbackIpEndPoint) || data.Length < PlayerIdsSize) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Discarded remote data from {Socket.LocalEndPoint} to {RemoteEndPoint} for player {PlayerId}."); + +#endif + return; + } + + await SendDataAsync(data).ConfigureAwait(false); + } + + protected override ValueTask DoReceiveDataAsync(Memory buffer, CancellationToken cancellation) + => Socket.ReceiveFromAsync(buffer[PlayerIdsSize..], SocketFlags.None, RemoteEndPoint, cancellation); + + protected override DataReceivedEventArgs ProcessReceivedData(Memory buffer, SocketReceiveFromResult socketReceiveFromResult) + => new(PlayerId, buffer[..(PlayerIdsSize + socketReceiveFromResult.ReceivedBytes)]); +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/V3RemotePlayerConnection.cs b/DXMainClient/Domain/Multiplayer/CnCNet/V3RemotePlayerConnection.cs new file mode 100644 index 000000000..98c887696 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/CnCNet/V3RemotePlayerConnection.cs @@ -0,0 +1,169 @@ +using System; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer.CnCNet; + +/// +/// Manages a player connection between a remote host and this application. +/// +internal sealed class V3RemotePlayerConnection : PlayerConnection +{ + private ushort localPort; + + protected override int GameStartReceiveTimeout => 1200000; + + protected override int GameInProgressReceiveTimeout => 1200000; + + public void SetUp(IPEndPoint remoteEndPoint, ushort localPort, uint gameLocalPlayerId, CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + PlayerId = gameLocalPlayerId; + RemoteEndPoint = remoteEndPoint; + this.localPort = localPort; + } + + /// + /// Occurs when the connection to the remote host succeeded. + /// + public event EventHandler RaiseConnectedEvent; + + /// + /// Occurs when the connection to the remote host could not be made. + /// + public event EventHandler RaiseConnectionFailedEvent; + + /// + /// Sends local game player data to the remote host. + /// + /// The data to send to the game. + /// The id of the player that receives the data. + public ValueTask SendDataToRemotePlayerAsync(Memory data, uint receiverId) + { + if (!BitConverter.TryWriteBytes(data.Span[..PlayerIdSize], PlayerId)) + throw new GameDataException(); + + if (!BitConverter.TryWriteBytes(data.Span[PlayerIdSize..(PlayerIdSize * 2)], receiverId)) + throw new GameDataException(); + + return SendDataAsync(data); + } + + protected override async ValueTask DoStartConnectionAsync() + { +#if DEBUG + Logger.Log($"{GetType().Name}: Attempting to establish a connection from port {localPort} to {RemoteEndPoint})."); +#else + Logger.Log($"{GetType().Name}: Attempting to establish a connection on port {localPort}."); +#endif + + Socket = new(SocketType.Dgram, ProtocolType.Udp); + + Socket.Bind(new IPEndPoint(IPAddress.IPv6Any, localPort)); + + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(MaximumPacketSize); + Memory buffer = memoryOwner.Memory[..MaximumPacketSize]; + + buffer.Span.Clear(); + + if (!BitConverter.TryWriteBytes(buffer.Span[..PlayerIdSize], PlayerId)) + throw new GameDataException(); + + using var timeoutCancellationTokenSource = new CancellationTokenSource(SendTimeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, CancellationToken); + + try + { + await Socket.SendToAsync(buffer, SocketFlags.None, RemoteEndPoint, linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (SocketException ex) + { +#if DEBUG + ProgramConstants.LogException(ex, $"Failed to establish connection from port {localPort} to {RemoteEndPoint}."); +#else + ProgramConstants.LogException(ex, $"Failed to establish connection on port {localPort}."); +#endif + OnRaiseConnectionFailedEvent(EventArgs.Empty); + + return; + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Failed to establish connection (time out) from port {localPort} to {RemoteEndPoint}."); +#else + Logger.Log($"{GetType().Name}: Failed to establish connection (time out) on port {localPort}."); +#endif + OnRaiseConnectionFailedEvent(EventArgs.Empty); + + return; + } + +#if DEBUG + Logger.Log($"{GetType().Name}: Connection from {Socket.LocalEndPoint} to {RemoteEndPoint} established."); +#else + Logger.Log($"{GetType().Name}: Connection on port {localPort} established."); +#endif + OnRaiseConnectedEvent(EventArgs.Empty); + } + + protected override ValueTask DoReceiveDataAsync(Memory buffer, CancellationToken cancellation) + => Socket.ReceiveFromAsync(buffer, SocketFlags.None, RemoteEndPoint, cancellation); + + protected override DataReceivedEventArgs ProcessReceivedData(Memory buffer, SocketReceiveFromResult socketReceiveFromResult) + { + if (socketReceiveFromResult.ReceivedBytes < PlayerIdsSize) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Invalid data packet from {socketReceiveFromResult.RemoteEndPoint}"); +#else + Logger.Log($"{GetType().Name}: Invalid data packet on port {localPort}"); +#endif + return null; + } + + Memory data = buffer[(PlayerIdSize * 2)..socketReceiveFromResult.ReceivedBytes]; + uint senderId = BitConverter.ToUInt32(buffer[..PlayerIdSize].Span); + uint receiverId = BitConverter.ToUInt32(buffer[PlayerIdSize..(PlayerIdSize * 2)].Span); + +#if DEBUG + Logger.Log($"{GetType().Name}: Received {senderId} -> {receiverId} from {socketReceiveFromResult.RemoteEndPoint} on {Socket.LocalEndPoint}."); + +#endif + if (receiverId != PlayerId) + { +#if DEBUG + Logger.Log($"{GetType().Name}: Invalid target (received: {receiverId}, expected: {PlayerId}) from {socketReceiveFromResult.RemoteEndPoint}."); +#else + Logger.Log($"{GetType().Name}: Invalid target (received: {receiverId}, expected: {PlayerId}) on port {localPort}."); +#endif + + return null; + } + + return new(senderId, data); + } + + private void OnRaiseConnectedEvent(EventArgs e) + { + EventHandler raiseEvent = RaiseConnectedEvent; + + raiseEvent?.Invoke(this, e); + } + + private void OnRaiseConnectionFailedEvent(EventArgs e) + { + EventHandler raiseEvent = RaiseConnectionFailedEvent; + + raiseEvent?.Invoke(this, e); + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/LAN/ClientIntCommandHandler.cs b/DXMainClient/Domain/Multiplayer/LAN/ClientIntCommandHandler.cs index 952229104..48e17bf16 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/ClientIntCommandHandler.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/ClientIntCommandHandler.cs @@ -20,7 +20,7 @@ public override bool Handle(string message) return false; int value; - bool success = int.TryParse(message.Substring(CommandName.Length + 1), out value); + bool success = int.TryParse(message[(CommandName.Length + 1)..], out value); if (!success) return false; diff --git a/DXMainClient/Domain/Multiplayer/LAN/ClientStringCommandHandler.cs b/DXMainClient/Domain/Multiplayer/LAN/ClientStringCommandHandler.cs index dd96bbf5d..4d3486488 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/ClientStringCommandHandler.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/ClientStringCommandHandler.cs @@ -16,7 +16,7 @@ public override bool Handle(string message) if (!message.StartsWith(CommandName)) return false; - action(message.Substring(CommandName.Length + 1)); + action(message[(CommandName.Length + 1)..]); return true; } } diff --git a/DXMainClient/Domain/Multiplayer/LAN/LANCommands.cs b/DXMainClient/Domain/Multiplayer/LAN/LANCommands.cs new file mode 100644 index 000000000..3ef602c38 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/LAN/LANCommands.cs @@ -0,0 +1,28 @@ +#pragma warning disable SA1310 +namespace DTAClient.Domain.Multiplayer.LAN; + +internal static class LANCommands +{ + public const string PLAYER_READY_REQUEST = "READY"; + public const string CHAT_GAME_LOADING_COMMAND = "CHAT"; + public const string CHAT_LOBBY_COMMAND = "GLCHAT"; + public const string RETURN = "RETURN"; + public const string GET_READY = "GETREADY"; + public const string PLAYER_OPTIONS_REQUEST = "POREQ"; + public const string PLAYER_OPTIONS_BROADCAST = "POPTS"; + public const string PLAYER_JOIN = "JOIN"; + public const string PLAYER_QUIT_COMMAND = "QUIT"; + public const string GAME_OPTIONS = "OPTS"; + public const string LAUNCH_GAME = "LAUNCH"; + public const string FILE_HASH = "FHASH"; + public const string DICE_ROLL = "DR"; + public const string PING = "PING"; + public const string OPTIONS = "OPTS"; + public const string PLAYER_EXTRA_OPTIONS = "PEOPTS"; + public const string READY_STATUS = "READY"; + public const string GAME_START = "START"; + public const string CHAT = "CHAT"; + public const string ALIVE = "ALIVE"; + public const string QUIT = "QUIT"; + public const string GAME = "GAME"; +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/LAN/LANPlayerInfo.cs b/DXMainClient/Domain/Multiplayer/LAN/LANPlayerInfo.cs index 974b062b7..dcc1dd013 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/LANPlayerInfo.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/LANPlayerInfo.cs @@ -1,54 +1,45 @@ using ClientCore; using Microsoft.Xna.Framework; -using Rampastring.Tools; -using Rampastring.XNAUI; using System; -using System.Collections.Generic; +using System.Buffers; using System.Net; -using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace DTAClient.Domain.Multiplayer.LAN { - public class LANPlayerInfo : PlayerInfo + internal sealed class LANPlayerInfo : PlayerInfo { public LANPlayerInfo(Encoding encoding) { this.encoding = encoding; - Port = PORT; + Port = ProgramConstants.LAN_INGAME_PORT; } public event EventHandler MessageReceived; public event EventHandler ConnectionLost; - public event EventHandler PlayerPinged; - private const int PORT = 1234; - private const int LOBBY_PORT = 1233; private const double SEND_PING_TIMEOUT = 10.0; private const double DROP_TIMEOUT = 20.0; - private const int LAN_PING_TIMEOUT = 1000; + private const int SEND_TIMEOUT = 1000; public TimeSpan TimeSinceLastReceivedMessage { get; set; } public TimeSpan TimeSinceLastSentMessage { get; set; } - public TcpClient TcpClient { get; private set; } + public Socket TcpClient { get; private set; } - NetworkStream networkStream; + private readonly Encoding encoding; - Encoding encoding; + private string overMessage = string.Empty; - string overMessage = string.Empty; - - public void SetClient(TcpClient client) + public void SetClient(Socket client) { if (TcpClient != null) throw new InvalidOperationException("TcpClient has already been set for this LANPlayerInfo!"); TcpClient = client; - TcpClient.SendTimeout = 1000; - networkStream = client.GetStream(); } /// @@ -56,14 +47,14 @@ public void SetClient(TcpClient client) /// /// Provides a snapshot of timing values. /// True if the player is still considered connected, otherwise false. - public bool Update(GameTime gameTime) + public async Task UpdateAsync(GameTime gameTime) { TimeSinceLastReceivedMessage += gameTime.ElapsedGameTime; TimeSinceLastSentMessage += gameTime.ElapsedGameTime; if (TimeSinceLastSentMessage > TimeSpan.FromSeconds(SEND_PING_TIMEOUT) || TimeSinceLastReceivedMessage > TimeSpan.FromSeconds(SEND_PING_TIMEOUT)) - SendMessage("PING"); + await SendMessageAsync(LANCommands.PING, default).ConfigureAwait(false); if (TimeSinceLastReceivedMessage > TimeSpan.FromSeconds(DROP_TIMEOUT)) return false; @@ -71,12 +62,12 @@ public bool Update(GameTime gameTime) return true; } - public override string IPAddress + public override IPAddress IPAddress { get { if (TcpClient != null) - return ((IPEndPoint)TcpClient.Client.RemoteEndPoint).Address.ToString(); + return ((IPEndPoint)TcpClient.RemoteEndPoint).Address.MapToIPv4(); return base.IPAddress; } @@ -84,7 +75,6 @@ public override string IPAddress set { base.IPAddress = value; - //throw new InvalidOperationException("Cannot set LANPlayerInfo's IPAddress!"); } } @@ -92,96 +82,80 @@ public override string IPAddress /// Sends a message to the player over the network. /// /// The message to send. - public void SendMessage(string message) + public async ValueTask SendMessageAsync(string message, CancellationToken cancellationToken) { - byte[] buffer; + message += ProgramConstants.LAN_MESSAGE_SEPARATOR; + + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = encoding.GetBytes(message.AsSpan(), buffer.Span); + using var timeoutCancellationTokenSource = new CancellationTokenSource(SEND_TIMEOUT); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken); - buffer = encoding.GetBytes(message + ProgramConstants.LAN_MESSAGE_SEPARATOR); + buffer = buffer[..bytes]; try { - networkStream.Write(buffer, 0, buffer.Length); - networkStream.Flush(); + await TcpClient.SendAsync(buffer, linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { } - catch + catch (Exception ex) { - Logger.Log("Sending message to " + ToString() + " failed!"); + ProgramConstants.LogException(ex, "Sending message to " + ToString() + " failed!"); } TimeSinceLastSentMessage = TimeSpan.Zero; } public override string ToString() - { - return Name + " (" + IPAddress + ")"; - } - - /// - /// Starts receiving messages from the player asynchronously. - /// - public void StartReceiveLoop() - { - Thread thread = new Thread(ReceiveMessages); - thread.Start(); - } + => Name + " (" + IPAddress + ")"; /// - /// Receives messages sent by the client, - /// and hands them over to another class via an event. + /// Starts receiving messages from the player. /// - private void ReceiveMessages() + public async ValueTask StartReceiveLoopAsync(CancellationToken cancellationToken) { - byte[] message = new byte[1024]; - - string msg = String.Empty; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(4096); - int bytesRead = 0; - - NetworkStream ns = TcpClient.GetStream(); - - while (true) + while (!cancellationToken.IsCancellationRequested) { - bytesRead = 0; + int bytesRead = 0; + Memory message = memoryOwner.Memory[..4096]; try { - //blocks until a client sends a message - bytesRead = ns.Read(message, 0, message.Length); + bytesRead = await TcpClient.ReceiveAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { } catch (Exception ex) { - //a socket error has occured - Logger.Log("Socket error with client " + Name + "; removing. Message: " + ex.Message); - ConnectionLost?.Invoke(this, EventArgs.Empty); - break; + ProgramConstants.LogException(ex, "Connection error with client " + Name + "; removing."); } if (bytesRead > 0) { - msg = encoding.GetString(message, 0, bytesRead); + string msg = encoding.GetString(message.Span[..bytesRead]); msg = overMessage + msg; - List commands = new List(); while (true) { - int index = msg.IndexOf(ProgramConstants.LAN_MESSAGE_SEPARATOR); + int index = msg.IndexOf(ProgramConstants.LAN_MESSAGE_SEPARATOR, StringComparison.OrdinalIgnoreCase); if (index == -1) { overMessage = msg; break; } - else - { - commands.Add(msg.Substring(0, index)); - msg = msg.Substring(index + 1); - } - } - foreach (string cmd in commands) - { - MessageReceived?.Invoke(this, new NetworkMessageEventArgs(cmd)); + MessageReceived?.Invoke(this, new NetworkMessageEventArgs(msg[..index])); + msg = msg[(index + 1)..]; } continue; @@ -191,24 +165,5 @@ private void ReceiveMessages() break; } } - - public void UpdatePing(WindowManager wm) - { - using (Ping p = new Ping()) - { - try - { - PingReply reply = p.Send(System.Net.IPAddress.Parse(IPAddress), LAN_PING_TIMEOUT); - if (reply.Status == IPStatus.Success) - Ping = Convert.ToInt32(reply.RoundtripTime); - - wm.AddCallback(PlayerPinged, this, EventArgs.Empty); - } - catch (PingException ex) - { - Logger.Log($"Caught an exception when pinging {Name} LAN player: {ex.Message}"); - } - } - } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/LAN/LANServerCommandHandler.cs b/DXMainClient/Domain/Multiplayer/LAN/LANServerCommandHandler.cs index b4a33a7cf..4d56a450e 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/LANServerCommandHandler.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/LANServerCommandHandler.cs @@ -1,6 +1,6 @@ namespace DTAClient.Domain.Multiplayer.LAN { - public abstract class LANServerCommandHandler + internal abstract class LANServerCommandHandler { public LANServerCommandHandler(string commandName) { @@ -11,4 +11,4 @@ public LANServerCommandHandler(string commandName) public abstract bool Handle(LANPlayerInfo pInfo, string message); } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/LAN/ServerNoParamCommandHandler.cs b/DXMainClient/Domain/Multiplayer/LAN/ServerNoParamCommandHandler.cs index 9146d8cca..083a7ad77 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/ServerNoParamCommandHandler.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/ServerNoParamCommandHandler.cs @@ -2,15 +2,15 @@ namespace DTAClient.Domain.Multiplayer.LAN { - public class ServerNoParamCommandHandler : LANServerCommandHandler + internal sealed class ServerNoParamCommandHandler : LANServerCommandHandler { - public ServerNoParamCommandHandler(string commandName, - Action handler) : base(commandName) + public ServerNoParamCommandHandler(string commandName, Action handler) + : base(commandName) { this.handler = handler; } - Action handler; + private Action handler; public override bool Handle(LANPlayerInfo pInfo, string message) { @@ -23,4 +23,4 @@ public override bool Handle(LANPlayerInfo pInfo, string message) return false; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/LAN/ServerStringCommandHandler.cs b/DXMainClient/Domain/Multiplayer/LAN/ServerStringCommandHandler.cs index d69ef71f3..536d0f4cf 100644 --- a/DXMainClient/Domain/Multiplayer/LAN/ServerStringCommandHandler.cs +++ b/DXMainClient/Domain/Multiplayer/LAN/ServerStringCommandHandler.cs @@ -2,10 +2,9 @@ namespace DTAClient.Domain.Multiplayer.LAN { - public class ServerStringCommandHandler : LANServerCommandHandler + internal sealed class ServerStringCommandHandler : LANServerCommandHandler { - public ServerStringCommandHandler(string commandName, - Action handler) + public ServerStringCommandHandler(string commandName, Action handler) : base(commandName) { this.handler = handler; @@ -15,12 +14,11 @@ public ServerStringCommandHandler(string commandName, public override bool Handle(LANPlayerInfo pInfo, string message) { - if (!message.StartsWith(CommandName) || - message.Length <= CommandName.Length + 1) + if (!message.StartsWith(CommandName) || message.Length <= CommandName.Length + 1) return false; handler(pInfo, message); return true; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/Map.cs b/DXMainClient/Domain/Multiplayer/Map.cs index a78b6859f..47b2e5562 100644 --- a/DXMainClient/Domain/Multiplayer/Map.cs +++ b/DXMainClient/Domain/Multiplayer/Map.cs @@ -9,11 +9,12 @@ using System.IO; using System.Linq; using System.Text.Json.Serialization; +using System.Threading.Tasks; using SixLabors.ImageSharp; using Color = Microsoft.Xna.Framework.Color; using Point = Microsoft.Xna.Framework.Point; -using Utilities = Rampastring.Tools.Utilities; -using static System.Collections.Specialized.BitVector32; +using ClientCore.Extensions; +using SixLabors.ImageSharp.PixelFormats; namespace DTAClient.Domain.Multiplayer { @@ -273,8 +274,10 @@ public void CalculateSHA() /// /// The configuration file for the multiplayer maps. /// True if loading the map succeeded, otherwise false. - public bool SetInfoFromMpMapsINI(IniFile iniFile) + public async ValueTask SetInfoFromMpMapsINIAsync(IniFile iniFile) { + await ValueTask.CompletedTask.ConfigureAwait(false); + try { string baseSectionName = iniFile.GetStringValue(BaseFilePath, "BaseSection", string.Empty); @@ -374,7 +377,7 @@ public bool SetInfoFromMpMapsINI(IniFile iniFile) CoopInfo.SetHouseInfos(section); } - if (MainClientConstants.USE_ISOMETRIC_CELLS) + if (ProgramConstants.USE_ISOMETRIC_CELLS) { localSize = section.GetStringValue("LocalSize", "0,0,0,0").Split(','); actualSize = section.GetStringValue("Size", "0,0,0,0").Split(','); @@ -428,8 +431,7 @@ public bool SetInfoFromMpMapsINI(IniFile iniFile) } catch (Exception ex) { - Logger.Log("Setting info for " + BaseFilePath + " failed! Reason: " + ex.Message); - PreStartup.LogException(ex); + ProgramConstants.LogException(ex, "Setting info for " + BaseFilePath + " failed!"); return false; } } @@ -455,9 +457,9 @@ private void GetTeamStartMappingPresets(IniSection section) TeamStartMappings = TeamStartMapping.FromListString(teamStartMappingPreset) }); } - catch (Exception e) + catch (Exception ex) { - Logger.Log($"Unable to parse team start mappings. Map: \"{Name}\", Error: {e.Message}"); + ProgramConstants.LogException(ex, $"Unable to parse team start mappings. Map: \"{Name}\"."); TeamStartMappingPresets = new List(); } } @@ -471,7 +473,7 @@ public List GetStartingLocationPreviewCoords(Point previewSize) foreach (string waypoint in waypoints) { - if (MainClientConstants.USE_ISOMETRIC_CELLS) + if (ProgramConstants.USE_ISOMETRIC_CELLS) startingLocations.Add(GetIsometricWaypointCoords(waypoint, actualSize, localSize, previewSize)); else startingLocations.Add(GetTDRAWaypointCoords(waypoint, x, y, width, height, previewSize)); @@ -483,7 +485,7 @@ public List GetStartingLocationPreviewCoords(Point previewSize) public Point MapPointToMapPreviewPoint(Point mapPoint, Point previewSize, int level) { - if (MainClientConstants.USE_ISOMETRIC_CELLS) + if (ProgramConstants.USE_ISOMETRIC_CELLS) return GetIsoTilePixelCoord(mapPoint.X, mapPoint.Y, actualSize, localSize, previewSize, level); return GetTDRACellPixelCoord(mapPoint.X, mapPoint.Y, x, y, width, height, previewSize); @@ -548,7 +550,7 @@ public bool SetInfoFromCustomMap() for (int i = 0; i < GameModes.Length; i++) { string gameMode = GameModes[i].Trim(); - GameModes[i] = gameMode.Substring(0, 1).ToUpperInvariant() + gameMode.Substring(1); + GameModes[i] = gameMode[..1].ToUpperInvariant() + gameMode[1..]; } MinPlayers = 0; @@ -598,7 +600,7 @@ public bool SetInfoFromCustomMap() localSize = iniFile.GetStringValue("Map", "LocalSize", "0,0,0,0").Split(','); actualSize = iniFile.GetStringValue("Map", "Size", "0,0,0,0").Split(','); - if (MainClientConstants.USE_ISOMETRIC_CELLS) + if (ProgramConstants.USE_ISOMETRIC_CELLS) { localSize = iniFile.GetStringValue("Map", "LocalSize", "0,0,0,0").Split(','); actualSize = iniFile.GetStringValue("Map", "Size", "0,0,0,0").Split(','); @@ -628,9 +630,9 @@ public bool SetInfoFromCustomMap() return true; } - catch + catch (Exception ex) { - Logger.Log("Loading custom map " + customMapFilePath + " failed!"); + ProgramConstants.LogException(ex, "Loading custom map " + customMapFilePath + " failed!"); return false; } } @@ -689,7 +691,8 @@ public Texture2D LoadPreviewTexture() if (!Official) { // Extract preview from the map itself - using Image preview = MapPreviewExtractor.ExtractMapPreview(GetCustomMapIniFile()); + // Logic should be refactored to not run on UI thread, for now use blocking call + using Image preview = Task.Run(() => MapPreviewExtractor.ExtractMapPreviewAsync(GetCustomMapIniFile())).HandleTask().Result; if (preview != null) { @@ -823,7 +826,7 @@ private static string HouseAllyIndexToString(int index) public string GetSizeString() { - if (MainClientConstants.USE_ISOMETRIC_CELLS) + if (ProgramConstants.USE_ISOMETRIC_CELLS) { if (actualSize == null || actualSize.Length < 4) return "Not available"; @@ -844,8 +847,8 @@ private static Point GetTDRAWaypointCoords(string waypoint, int x, int y, int wi return new Point(0, 0); // https://modenc.renegadeprojects.com/Waypoints - int waypointX = waypointCoordsInt % MainClientConstants.TDRA_WAYPOINT_COEFFICIENT; - int waypointY = waypointCoordsInt / MainClientConstants.TDRA_WAYPOINT_COEFFICIENT; + int waypointX = waypointCoordsInt % ProgramConstants.TDRA_WAYPOINT_COEFFICIENT; + int waypointY = waypointCoordsInt / ProgramConstants.TDRA_WAYPOINT_COEFFICIENT; return GetTDRACellPixelCoord(waypointX, waypointY, x, y, width, height, previewSizePoint); } @@ -875,8 +878,8 @@ private static Point GetIsometricWaypointCoords(string waypoint, string[] actual int xCoordIndex = parts[0].Length - 3; - int isoTileY = Convert.ToInt32(parts[0].Substring(0, xCoordIndex), CultureInfo.InvariantCulture); - int isoTileX = Convert.ToInt32(parts[0].Substring(xCoordIndex), CultureInfo.InvariantCulture); + int isoTileY = Convert.ToInt32(parts[0][..xCoordIndex], CultureInfo.InvariantCulture); + int isoTileX = Convert.ToInt32(parts[0][xCoordIndex..], CultureInfo.InvariantCulture); int level = 0; @@ -891,15 +894,15 @@ private static Point GetIsoTilePixelCoord(int isoTileX, int isoTileY, string[] a int rx = isoTileX - isoTileY + Convert.ToInt32(actualSizeValues[2], CultureInfo.InvariantCulture) - 1; int ry = isoTileX + isoTileY - Convert.ToInt32(actualSizeValues[2], CultureInfo.InvariantCulture) - 1; - int pixelPosX = rx * MainClientConstants.MAP_CELL_SIZE_X / 2; - int pixelPosY = ry * MainClientConstants.MAP_CELL_SIZE_Y / 2 - level * MainClientConstants.MAP_CELL_SIZE_Y / 2; + int pixelPosX = rx * ProgramConstants.MAP_CELL_SIZE_X / 2; + int pixelPosY = ry * ProgramConstants.MAP_CELL_SIZE_Y / 2 - level * ProgramConstants.MAP_CELL_SIZE_Y / 2; - pixelPosX = pixelPosX - (Convert.ToInt32(localSizeValues[0], CultureInfo.InvariantCulture) * MainClientConstants.MAP_CELL_SIZE_X); - pixelPosY = pixelPosY - (Convert.ToInt32(localSizeValues[1], CultureInfo.InvariantCulture) * MainClientConstants.MAP_CELL_SIZE_Y); + pixelPosX = pixelPosX - (Convert.ToInt32(localSizeValues[0], CultureInfo.InvariantCulture) * ProgramConstants.MAP_CELL_SIZE_X); + pixelPosY = pixelPosY - (Convert.ToInt32(localSizeValues[1], CultureInfo.InvariantCulture) * ProgramConstants.MAP_CELL_SIZE_Y); // Calculate map size - int mapSizeX = Convert.ToInt32(localSizeValues[2], CultureInfo.InvariantCulture) * MainClientConstants.MAP_CELL_SIZE_X; - int mapSizeY = Convert.ToInt32(localSizeValues[3], CultureInfo.InvariantCulture) * MainClientConstants.MAP_CELL_SIZE_Y; + int mapSizeX = Convert.ToInt32(localSizeValues[2], CultureInfo.InvariantCulture) * ProgramConstants.MAP_CELL_SIZE_X; + int mapSizeY = Convert.ToInt32(localSizeValues[3], CultureInfo.InvariantCulture) * ProgramConstants.MAP_CELL_SIZE_Y; double ratioX = Convert.ToDouble(pixelPosX) / mapSizeX; double ratioY = Convert.ToDouble(pixelPosY) / mapSizeY; diff --git a/DXMainClient/Domain/Multiplayer/MapLoader.cs b/DXMainClient/Domain/Multiplayer/MapLoader.cs index 04c91afa8..deaf288d5 100644 --- a/DXMainClient/Domain/Multiplayer/MapLoader.cs +++ b/DXMainClient/Domain/Multiplayer/MapLoader.cs @@ -29,11 +29,6 @@ public class MapLoader public GameModeMapCollection GameModeMaps; - /// - /// An event that is fired when the maps have been loaded. - /// - public event EventHandler MapLoadingComplete; - /// /// A list of game mode aliases. /// Every game mode entry that exists in this dictionary will get @@ -55,15 +50,10 @@ public class MapLoader /// private string[] AllowedGameModes = ClientConfiguration.Instance.AllowedCustomGameModes.Split(','); - /// - /// Loads multiplayer map info asynchonously. - /// - public Task LoadMapsAsync() => Task.Run(LoadMaps); - /// /// Load maps based on INI info as well as those in the custom maps directory. /// - public void LoadMaps() + public async Task LoadMapsAsync() { string mpMapsPath = SafePath.CombineFilePath(ProgramConstants.GamePath, ClientConfiguration.Instance.MPMapsIniPath); @@ -73,16 +63,14 @@ public void LoadMaps() LoadGameModes(mpMapsIni); LoadGameModeAliases(mpMapsIni); - LoadMultiMaps(mpMapsIni); - LoadCustomMaps(); + await LoadMultiMapsAsync(mpMapsIni).ConfigureAwait(false); + await LoadCustomMapsAsync().ConfigureAwait(false); GameModes.RemoveAll(g => g.Maps.Count < 1); GameModeMaps = new GameModeMapCollection(GameModes); - - MapLoadingComplete?.Invoke(this, EventArgs.Empty); } - private void LoadMultiMaps(IniFile mpMapsIni) + private async ValueTask LoadMultiMapsAsync(IniFile mpMapsIni) { List keys = mpMapsIni.GetSectionKeys(MultiMapsSection); @@ -108,7 +96,7 @@ private void LoadMultiMaps(IniFile mpMapsIni) var map = new Map(mapFilePathValue, false); - if (!map.SetInfoFromMpMapsINI(mpMapsIni)) + if (!await map.SetInfoFromMpMapsINIAsync(mpMapsIni).ConfigureAwait(false)) continue; maps.Add(map); @@ -152,7 +140,7 @@ private void LoadGameModeAliases(IniFile mpMapsIni) } } - private void LoadCustomMaps() + private async ValueTask LoadCustomMapsAsync() { DirectoryInfo customMapsDirectory = SafePath.GetDirectory(ProgramConstants.GamePath, CUSTOM_MAPS_DIRECTORY); @@ -163,18 +151,16 @@ private void LoadCustomMaps() } IEnumerable mapFiles = customMapsDirectory.EnumerateFiles($"*{MAP_FILE_EXTENSION}"); - ConcurrentDictionary customMapCache = LoadCustomMapCache(); + ConcurrentDictionary customMapCache = await LoadCustomMapCacheAsync().ConfigureAwait(false); var localMapSHAs = new List(); - var tasks = new List(); foreach (FileInfo mapFile in mapFiles) { - // this must be Task.Factory.StartNew for XNA/.Net 4.0 compatibility - tasks.Add(Task.Factory.StartNew(() => + tasks.Add(Task.Run(() => { - string baseFilePath = mapFile.FullName.Substring(ProgramConstants.GamePath.Length); - baseFilePath = baseFilePath.Substring(0, baseFilePath.Length - 4); + string baseFilePath = mapFile.FullName[ProgramConstants.GamePath.Length..]; + baseFilePath = baseFilePath[..^4]; var map = new Map(baseFilePath .Replace(Path.DirectorySeparatorChar, '/') @@ -186,7 +172,7 @@ private void LoadCustomMaps() })); } - Task.WaitAll(tasks.ToArray()); + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(tasks.ToArray()).ConfigureAwait(false); // remove cached maps that no longer exist locally foreach (var missingSHA in customMapCache.Keys.Where(cachedSHA => !localMapSHAs.Contains(cachedSHA))) @@ -195,7 +181,7 @@ private void LoadCustomMaps() } // save cache - CacheCustomMaps(customMapCache); + await CacheCustomMapsAsync(customMapCache).ConfigureAwait(false); foreach (Map map in customMapCache.Values) { @@ -207,40 +193,60 @@ private void LoadCustomMaps() /// Save cache of custom maps. /// /// Custom maps to cache - private void CacheCustomMaps(ConcurrentDictionary customMaps) + private async ValueTask CacheCustomMapsAsync(ConcurrentDictionary customMaps) { var customMapCache = new CustomMapCache { Maps = customMaps, Version = CurrentCustomMapCacheVersion }; - var jsonData = JsonSerializer.Serialize(customMapCache, jsonSerializerOptions); + var fileStream = new FileStream(CUSTOM_MAPS_CACHE, new FileStreamOptions + { + Access = FileAccess.Write, + Mode = FileMode.Create, + Options = FileOptions.Asynchronous, + Share = FileShare.None + }); - File.WriteAllText(CUSTOM_MAPS_CACHE, jsonData); + await using (fileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(fileStream, customMapCache, jsonSerializerOptions).ConfigureAwait(false); + } } /// /// Load previously cached custom maps /// /// - private ConcurrentDictionary LoadCustomMapCache() + private async ValueTask> LoadCustomMapCacheAsync() { try { - var jsonData = File.ReadAllText(CUSTOM_MAPS_CACHE); - - var customMapCache = JsonSerializer.Deserialize(jsonData, jsonSerializerOptions); + var jsonData = new FileStream(CUSTOM_MAPS_CACHE, new FileStreamOptions + { + Access = FileAccess.Read, + Mode = FileMode.Open, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan, + Share = FileShare.None + }); + CustomMapCache customMapCache; + + await using (jsonData.ConfigureAwait(false)) + { + customMapCache = await JsonSerializer.DeserializeAsync(jsonData, jsonSerializerOptions).ConfigureAwait(false); + } - var customMaps = customMapCache?.Version == CurrentCustomMapCacheVersion && customMapCache.Maps != null - ? customMapCache.Maps : new ConcurrentDictionary(); + ConcurrentDictionary customMaps = !(customMapCache?.Version != CurrentCustomMapCacheVersion || customMapCache.Maps == null) ? customMapCache.Maps : new(); - foreach (var customMap in customMaps.Values) + foreach (Map customMap in customMaps.Values) customMap.AfterDeserialize(); return customMaps; } - catch (Exception) + catch (Exception ex) { + ProgramConstants.LogException(ex); + SafePath.DeleteFileIfExists(CUSTOM_MAPS_CACHE); return new ConcurrentDictionary(); } } diff --git a/DXMainClient/Domain/Multiplayer/MapPreviewExtractor.cs b/DXMainClient/Domain/Multiplayer/MapPreviewExtractor.cs index 9e7b5263f..31ca6f5f2 100644 --- a/DXMainClient/Domain/Multiplayer/MapPreviewExtractor.cs +++ b/DXMainClient/Domain/Multiplayer/MapPreviewExtractor.cs @@ -4,6 +4,7 @@ using System.IO; using System.IO.Compression; using System.Text; +using System.Threading.Tasks; using ClientCore; using Rampastring.Tools; using lzo.net; @@ -24,7 +25,7 @@ public static class MapPreviewExtractor /// /// Map file. /// Bitmap of map preview image, or null if preview could not be extracted. - public static Image ExtractMapPreview(IniFile mapIni) + public static async Task> ExtractMapPreviewAsync(IniFile mapIni) { List sectionKeys = mapIni.GetSectionKeys("PreviewPack"); @@ -66,13 +67,13 @@ public static Image ExtractMapPreview(IniFile mapIni) { dataSource = Convert.FromBase64String(sb.ToString()); } - catch (Exception) + catch (Exception ex) { - Logger.Log("MapPreviewExtractor: " + baseFilename + " - [PreviewPack] is malformed, unable to extract preview."); + ProgramConstants.LogException(ex, "MapPreviewExtractor: " + baseFilename + " - [PreviewPack] is malformed, unable to extract preview."); return null; } - byte[] dataDest = DecompressPreviewData(dataSource, previewWidth * previewHeight * 3, out string errorMessage); + (byte[] dataDest, string errorMessage) = await DecompressPreviewDataAsync(dataSource, previewWidth * previewHeight * 3).ConfigureAwait(false); if (errorMessage != null) { @@ -80,7 +81,7 @@ public static Image ExtractMapPreview(IniFile mapIni) return null; } - Image bitmap = CreatePreviewBitmapFromImageData(previewWidth, previewHeight, dataDest, out errorMessage); + (Image bitmap, errorMessage) = CreatePreviewBitmapFromImageData(previewWidth, previewHeight, dataDest); if (errorMessage != null) { @@ -96,14 +97,14 @@ public static Image ExtractMapPreview(IniFile mapIni) /// /// Array of compressed map preview image data. /// Size of decompressed preview image data. - /// Will be set to error message if something went wrong, otherwise null. /// Array of decompressed preview image data if successfully decompressed, otherwise null. - private static byte[] DecompressPreviewData(byte[] dataSource, int decompressedDataSize, out string errorMessage) + private static async ValueTask<(byte[] Data, string ErrorMessage)> DecompressPreviewDataAsync(byte[] dataSource, int decompressedDataSize) { try { byte[] dataDest = new byte[decompressedDataSize]; - int readBytes = 0, writtenBytes = 0; + int readBytes = 0; + int writtenBytes = 0; while (true) { @@ -121,23 +122,27 @@ private static byte[] DecompressPreviewData(byte[] dataSource, int decompressedD if (readBytes + sizeCompressed > dataSource.Length || writtenBytes + sizeUncompressed > dataDest.Length) { - errorMessage = "Preview data does not match preview size or the data is corrupted, unable to extract preview."; - return null; + return (null, "Preview data does not match preview size or the data is corrupted, unable to extract preview."); + } + + var stream = new LzoStream(new MemoryStream(dataSource, readBytes, sizeCompressed), CompressionMode.Decompress); + + await using (stream.ConfigureAwait(false)) + { + await stream.ReadAsync(dataDest, writtenBytes, sizeUncompressed).ConfigureAwait(false); } - LzoStream stream = new LzoStream(new MemoryStream(dataSource, readBytes, sizeCompressed), CompressionMode.Decompress); - stream.Read(dataDest, writtenBytes, sizeUncompressed); readBytes += sizeCompressed; writtenBytes += sizeUncompressed; } - errorMessage = null; - return dataDest; + return (dataDest, null); } - catch (Exception e) + catch (Exception ex) { - errorMessage = "Error encountered decompressing preview data. Message: " + e.Message; - return null; + ProgramConstants.LogException(ex, "Error encountered decompressing preview data."); + + return (null, "Error encountered decompressing preview data. Message: " + ex.Message); } } @@ -147,17 +152,15 @@ private static byte[] DecompressPreviewData(byte[] dataSource, int decompressedD /// Width of the bitmap. /// Height of the bitmap. /// Raw image pixel data in 24-bit RGB format. - /// Will be set to error message if something went wrong, otherwise null. /// Bitmap based on the provided dimensions and raw image data, or null if length of image data does not match the provided dimensions or if something went wrong. - private static Image CreatePreviewBitmapFromImageData(int width, int height, byte[] imageData, out string errorMessage) + private static (Image Image, string ErrorMessage) CreatePreviewBitmapFromImageData(int width, int height, byte[] imageData) { const int pixelFormatBitCount = 24; const int pixelFormatByteCount = pixelFormatBitCount / 8; if (imageData.Length != width * height * pixelFormatByteCount) { - errorMessage = "Provided preview image dimensions do not match preview image data length."; - return null; + return (null, "Provided preview image dimensions do not match preview image data length."); } try @@ -209,14 +212,13 @@ private static Image CreatePreviewBitmapFromImageData(int width, int height, byt } } - errorMessage = null; - - return image; + return (image, null); } - catch (Exception e) + catch (Exception ex) { - errorMessage = "Error encountered creating preview bitmap. Message: " + e.Message; - return null; + ProgramConstants.LogException(ex, "Error encountered creating preview bitmap."); + + return (null, "Error encountered creating preview bitmap. Message: " + ex.Message); } } } diff --git a/DXMainClient/Domain/Multiplayer/MultiplayerColor.cs b/DXMainClient/Domain/Multiplayer/MultiplayerColor.cs index e23204cc1..88311cf91 100644 --- a/DXMainClient/Domain/Multiplayer/MultiplayerColor.cs +++ b/DXMainClient/Domain/Multiplayer/MultiplayerColor.cs @@ -62,9 +62,9 @@ public static List LoadColors() MultiplayerColor mpColor = MultiplayerColor.CreateFromStringArray(key.L10N($"INI:Colors:{key}"), values); mpColors.Add(mpColor); } - catch + catch (Exception ex) { - throw new ClientConfigurationException("Invalid MPColor specified in GameOptions.ini: " + key); + throw new ClientConfigurationException("Invalid MPColor specified in GameOptions.ini: " + key, ex); } } diff --git a/DXMainClient/Domain/Multiplayer/NetworkHelper.cs b/DXMainClient/Domain/Multiplayer/NetworkHelper.cs new file mode 100644 index 000000000..a5e39ece2 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/NetworkHelper.cs @@ -0,0 +1,390 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; +using Rampastring.Tools; + +namespace DTAClient.Domain.Multiplayer; + +internal static class NetworkHelper +{ + private const string PingHost = "cncnet.org"; + private const int PingTimeout = 1000; + + private static readonly IReadOnlyCollection SupportedAddressFamilies = new[] + { + AddressFamily.InterNetwork, + AddressFamily.InterNetworkV6 + }.AsReadOnly(); + + public static bool HasIPv6Internet() + => Socket.OSSupportsIPv6 && GetLocalPublicIpV6Address() is not null; + + public static bool HasIPv4Internet() + => Socket.OSSupportsIPv4 && GetLocalAddresses().Any(q => q.AddressFamily is AddressFamily.InterNetwork); + + public static IEnumerable GetLocalAddresses() + => GetUniCastIpAddresses() + .Select(q => q.Address); + + public static IEnumerable GetPublicIpAddresses() + => GetLocalAddresses() + .Where(q => !IsPrivateIpAddress(q)); + + public static IEnumerable GetPrivateIpAddresses() + => GetLocalAddresses() + .Where(IsPrivateIpAddress); + + [SupportedOSPlatform("windows")] + public static IEnumerable GetWindowsLanUniCastIpAddresses() + => GetLanUniCastIpAddresses() + .Where(q => q.SuffixOrigin is not SuffixOrigin.WellKnown); + + public static IEnumerable GetLanUniCastIpAddresses() + => GetIpInterfaces() + .SelectMany(q => q.UnicastAddresses) + .Where(q => SupportedAddressFamilies.Contains(q.Address.AddressFamily)); + + public static IEnumerable GetMulticastAddresses() + => GetIpInterfaces() + .SelectMany(q => q.MulticastAddresses.Select(r => r.Address)) + .Where(q => SupportedAddressFamilies.Contains(q.AddressFamily)); + + public static Uri FormatUri(string scheme, Uri uri, ushort port, string path) + { + string[] pathAndQuery = path.Split('?'); + var uriBuilder = new UriBuilder(uri) + { + Scheme = scheme, + Host = uri.IdnHost, + Port = port, + Path = pathAndQuery.First(), + Query = pathAndQuery.Skip(1).SingleOrDefault() + }; + + return uriBuilder.Uri; + } + + public static Uri FormatUri(IPEndPoint ipEndPoint, string scheme = null, string path = null) + { + var uriBuilder = new UriBuilder(scheme ?? Uri.UriSchemeHttps, ipEndPoint.Address.ToString(), ipEndPoint.Port, path); + + return uriBuilder.Uri; + } + + private static IEnumerable GetUniCastIpAddresses() + => GetIpInterfaces() + .SelectMany(q => q.UnicastAddresses) + .Where(q => SupportedAddressFamilies.Contains(q.Address.AddressFamily)); + + private static IEnumerable GetIpInterfaces() + => NetworkInterface.GetAllNetworkInterfaces() + .Where(q => q.OperationalStatus is OperationalStatus.Up && q.NetworkInterfaceType is not NetworkInterfaceType.Loopback) + .Select(q => q.GetIPProperties()) + .Where(q => q.GatewayAddresses.Any()); + + [SupportedOSPlatform("windows")] + private static IEnumerable<(IPAddress IpAddress, PrefixOrigin PrefixOrigin, SuffixOrigin SuffixOrigin)> GetWindowsPublicIpAddresses() + => GetUniCastIpAddresses() + .Where(q => !IsPrivateIpAddress(q.Address)) + .Select(q => (q.Address, q.PrefixOrigin, q.SuffixOrigin)); + + public static IPAddress GetIpV4BroadcastAddress(UnicastIPAddressInformation unicastIpAddressInformation) + { + uint ipAddress = BitConverter.ToUInt32(unicastIpAddressInformation.Address.GetAddressBytes(), 0); + uint ipMaskV4 = BitConverter.ToUInt32(unicastIpAddressInformation.IPv4Mask.GetAddressBytes(), 0); + uint broadCastIpAddress = ipAddress | ~ipMaskV4; + + return new(BitConverter.GetBytes(broadCastIpAddress)); + } + + public static IPAddress GetLocalPublicIpV6Address() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return GetPublicIpAddresses().FirstOrDefault(q => q.AddressFamily is AddressFamily.InterNetworkV6); + + var localIpV6Addresses = GetWindowsPublicIpAddresses() + .Where(q => q.IpAddress.AddressFamily is AddressFamily.InterNetworkV6).ToList(); + + (IPAddress IpAddress, PrefixOrigin PrefixOrigin, SuffixOrigin SuffixOrigin) foundLocalPublicIpV6Address = localIpV6Addresses + .FirstOrDefault(q => q.PrefixOrigin is PrefixOrigin.RouterAdvertisement && q.SuffixOrigin is SuffixOrigin.LinkLayerAddress); + + if (foundLocalPublicIpV6Address.IpAddress is null) + { + foundLocalPublicIpV6Address = localIpV6Addresses.FirstOrDefault( + q => q.PrefixOrigin is PrefixOrigin.Dhcp && q.SuffixOrigin is SuffixOrigin.OriginDhcp); + } + + return foundLocalPublicIpV6Address.IpAddress; + } + + public static async ValueTask TracePublicIpV4Address(CancellationToken cancellationToken) + { + try + { + IPAddress[] ipAddresses = await Dns.GetHostAddressesAsync(PingHost, cancellationToken).ConfigureAwait(false); + using var ping = new Ping(); + + foreach (IPAddress ipAddress in ipAddresses.Where(q => q.AddressFamily is AddressFamily.InterNetwork)) + { + PingReply pingReply = await ping.SendPingAsync(ipAddress, PingTimeout).ConfigureAwait(false); + + if (pingReply.Status is not IPStatus.Success) + continue; + + IPAddress pingIpAddress = null; + int ttl = 1; + + while (!ipAddress.Equals(pingIpAddress)) + { + pingReply = await ping.SendPingAsync(ipAddress, PingTimeout, Array.Empty(), new(ttl++, false)).ConfigureAwait(false); + pingIpAddress = pingReply.Address; + + if (ipAddress.Equals(pingIpAddress)) + break; + + if (!IsPrivateIpAddress(pingReply.Address)) + return pingReply.Address; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ProgramConstants.LogException(ex, "IP trace detection failed."); + } + + return null; + } + + public static async ValueTask PingAsync(IPAddress ipAddress) + { + if ((ipAddress.AddressFamily is AddressFamily.InterNetworkV6 && !HasIPv6Internet()) + || (ipAddress.AddressFamily is AddressFamily.InterNetwork && !HasIPv4Internet())) + { + return null; + } + + using var ping = new Ping(); + + try + { + PingReply pingResult = await ping.SendPingAsync(ipAddress, PingTimeout).ConfigureAwait(false); + + if (pingResult.Status is IPStatus.Success) + return pingResult.RoundtripTime; + } + catch (PingException ex) + { + ProgramConstants.LogException(ex, "Ping failed."); + } + + return null; + } + + public static async ValueTask<(IPAddress IPAddress, List<(ushort InternalPort, ushort ExternalPort)> PortMapping)> PerformStunAsync( + List stunServerIpAddresses, List p2pReservedPorts, AddressFamily addressFamily, CancellationToken cancellationToken) + { + Logger.Log($"P2P: Using STUN to detect {addressFamily} address."); + + var stunPortMapping = new List<(ushort InternalPort, ushort ExternalPort)>(); + var matchingStunServerIpAddresses = stunServerIpAddresses.Where(q => q.AddressFamily == addressFamily).ToList(); + + if (!matchingStunServerIpAddresses.Any()) + { + Logger.Log($"P2P: No {addressFamily} STUN servers found."); + + return (null, stunPortMapping); + } + + IPAddress stunPublicAddress = null; + IPAddress stunServerIpAddress = null; + + foreach (IPAddress matchingStunServerIpAddress in matchingStunServerIpAddresses.TakeWhile(_ => stunPublicAddress is null)) + { + stunServerIpAddress = matchingStunServerIpAddress; + + foreach (ushort p2pReservedPort in p2pReservedPorts) + { + IPEndPoint stunPublicIpEndPoint = await PerformStunAsync( + stunServerIpAddress, p2pReservedPort, addressFamily, cancellationToken).ConfigureAwait(false); + + if (stunPublicIpEndPoint is null) + break; + + stunPublicAddress = stunPublicIpEndPoint.Address; + + if (p2pReservedPort != stunPublicIpEndPoint.Port) + stunPortMapping.Add(new(p2pReservedPort, (ushort)stunPublicIpEndPoint.Port)); + } + } + + if (stunPublicAddress is not null) + Logger.Log($"P2P: {addressFamily} STUN detection succeeded using server {stunServerIpAddress}."); + else + Logger.Log($"P2P: {addressFamily} STUN detection failed."); + + if (stunPortMapping.Any()) + { + Logger.Log($"P2P: {addressFamily} STUN detection detected mapped ports, running STUN keep alive."); +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + KeepStunAliveAsync( + stunServerIpAddress, + stunPortMapping.Select(q => q.InternalPort).ToList(), cancellationToken).HandleTask(); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + } + + return (stunPublicAddress, stunPortMapping); + } + + /// + /// Returns the specified amount of free UDP port numbers. + /// + /// List of UDP port numbers which are additionally excluded. + /// The number of free ports to return. + /// A free UDP port number on the current system. + public static IEnumerable GetFreeUdpPorts(IEnumerable excludedPorts, ushort numberOfPorts) + { + IPEndPoint[] endPoints = IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners(); + var activeV4AndV6Ports = endPoints.Select(q => (ushort)q.Port).ToArray().Concat(excludedPorts).Distinct().ToList(); + ushort foundPortCount = 0; + + while (foundPortCount != numberOfPorts) + { + using var socket = new Socket(SocketType.Dgram, ProtocolType.Udp); + + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + ushort foundPort = (ushort)((IPEndPoint)socket.LocalEndPoint).Port; + + if (!activeV4AndV6Ports.Contains(foundPort)) + { + activeV4AndV6Ports.Add(foundPort); + + foundPortCount++; + + yield return foundPort; + } + } + } + + public static bool IsPrivateIpAddress(IPAddress ipAddress) + => ipAddress.AddressFamily switch + { + AddressFamily.InterNetworkV6 => ipAddress.IsIPv6SiteLocal + || ipAddress.IsIPv6UniqueLocal + || ipAddress.IsIPv6LinkLocal, + AddressFamily.InterNetwork => IsInRange("10.0.0.0", "10.255.255.255", ipAddress) + || IsInRange("172.16.0.0", "172.31.255.255", ipAddress) + || IsInRange("192.168.0.0", "192.168.255.255", ipAddress) + || IsInRange("169.254.0.0", "169.254.255.255", ipAddress) + || IsInRange("127.0.0.0", "127.255.255.255", ipAddress) + || IsInRange("0.0.0.0", "0.255.255.255", ipAddress), + _ => throw new ArgumentOutOfRangeException(nameof(ipAddress.AddressFamily), ipAddress.AddressFamily, null), + }; + + private static bool IsInRange(string startIpAddress, string endIpAddress, IPAddress address) + { + uint ipStart = BitConverter.ToUInt32(IPAddress.Parse(startIpAddress).GetAddressBytes().Reverse().ToArray(), 0); + uint ipEnd = BitConverter.ToUInt32(IPAddress.Parse(endIpAddress).GetAddressBytes().Reverse().ToArray(), 0); + uint ip = BitConverter.ToUInt32(address.GetAddressBytes().Reverse().ToArray(), 0); + + return ip >= ipStart && ip <= ipEnd; + } + + private static async ValueTask PerformStunAsync(IPAddress stunServerIpAddress, ushort localPort, AddressFamily addressFamily, CancellationToken cancellationToken) + { + const short stunId = 26262; + const int stunPort1 = 3478; + const int stunPort2 = 8054; + const int stunSize = 48; + int[] stunPorts = { stunPort1, stunPort2 }; + using var socket = new Socket(addressFamily, SocketType.Dgram, ProtocolType.Udp); + short stunIdNetworkOrder = IPAddress.HostToNetworkOrder(stunId); + using IMemoryOwner receiveMemoryOwner = MemoryPool.Shared.Rent(stunSize); + Memory buffer = receiveMemoryOwner.Memory[..stunSize]; + + if (!BitConverter.TryWriteBytes(buffer.Span, stunIdNetworkOrder)) + throw new(); + + IPEndPoint stunServerIpEndPoint = null; + int addressBytes = stunServerIpAddress.GetAddressBytes().Length; + const int portBytes = sizeof(ushort); + + socket.Bind(new IPEndPoint(addressFamily is AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort)); + + foreach (int stunPort in stunPorts) + { + try + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(PingTimeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken); + + stunServerIpEndPoint = new(stunServerIpAddress, stunPort); + + await socket.SendToAsync(buffer, stunServerIpEndPoint, linkedCancellationTokenSource.Token).ConfigureAwait(false); + + SocketReceiveFromResult socketReceiveFromResult = await socket.ReceiveFromAsync( + buffer, SocketFlags.None, stunServerIpEndPoint, linkedCancellationTokenSource.Token).ConfigureAwait(false); + + buffer = buffer[..socketReceiveFromResult.ReceivedBytes]; + + // de-obfuscate + for (int i = 0; i < addressBytes + portBytes; i++) + buffer.Span[i] ^= 0x20; + + ReadOnlyMemory publicIpAddressBytes = buffer[..addressBytes]; + var publicIpAddress = new IPAddress(publicIpAddressBytes.Span); + ReadOnlyMemory publicPortBytes = buffer[addressBytes..(addressBytes + portBytes)]; + short publicPortNetworkOrder = BitConverter.ToInt16(publicPortBytes.Span); + short publicPortHostOrder = IPAddress.NetworkToHostOrder(publicPortNetworkOrder); + ushort publicPort = (ushort)publicPortHostOrder; + + return new(publicIpAddress, publicPort); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + Logger.Log($"P2P: STUN server {stunServerIpEndPoint} unreachable."); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, $"P2P: STUN server {stunServerIpEndPoint} unreachable."); + } + } + + return null; + } + + private static async Task KeepStunAliveAsync(IPAddress stunServerIpAddress, List localPorts, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + foreach (ushort localPort in localPorts) + { + await PerformStunAsync(stunServerIpAddress, localPort, stunServerIpAddress.AddressFamily, cancellationToken).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + Logger.Log($"P2P: {stunServerIpAddress.AddressFamily} STUN keep alive stopped."); + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, "P2P: STUN keep alive failed."); + } + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/P2PPlayer.cs b/DXMainClient/Domain/Multiplayer/P2PPlayer.cs new file mode 100644 index 000000000..198eb9297 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/P2PPlayer.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Net; + +namespace DTAClient.Domain.Multiplayer; + +internal readonly record struct P2PPlayer( + string RemotePlayerName, + ushort[] RemoteIpV6Ports, + ushort[] RemoteIpV4Ports, + List<(IPAddress RemoteIpAddress, long Ping)> LocalPingResults, + List<(IPAddress RemoteIpAddress, long Ping)> RemotePingResults); \ No newline at end of file diff --git a/DXMainClient/Domain/Multiplayer/PlayerExtraOptions.cs b/DXMainClient/Domain/Multiplayer/PlayerExtraOptions.cs index e3a34d210..11667ba0d 100644 --- a/DXMainClient/Domain/Multiplayer/PlayerExtraOptions.cs +++ b/DXMainClient/Domain/Multiplayer/PlayerExtraOptions.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using DTAClient.Domain.Multiplayer.CnCNet; +using DTAClient.Domain.Multiplayer.LAN; namespace DTAClient.Domain.Multiplayer { @@ -10,14 +12,10 @@ public class PlayerExtraOptions { private static string INVALID_OPTIONS_MESSAGE => "Invalid player extra options message".L10N("Client:Main:InvalidPlayerExtraOptionsMessage"); private static string MAPPING_ERROR_PREFIX => "Auto Allying:".L10N("Client:Main:AutoAllyingPrefix"); - protected static string NOT_ALL_MAPPINGS_ASSIGNED => MAPPING_ERROR_PREFIX + " " + "You must have all mappings assigned.".L10N("Client:Main:NotAllMappingsAssigned"); protected static string MULTIPLE_MAPPINGS_ASSIGNED_TO_SAME_START => MAPPING_ERROR_PREFIX + " " + "Multiple mappings assigned to the same start location.".L10N("Client:Main:MultipleMappingsAssigned"); protected static string ONLY_ONE_TEAM => MAPPING_ERROR_PREFIX + " " + "You must have more than one team assigned.".L10N("Client:Main:OnlyOneTeam"); private const char MESSAGE_SEPARATOR = ';'; - public const string CNCNET_MESSAGE_KEY = "PEO"; - public const string LAN_MESSAGE_KEY = "PEOPTS"; - public bool IsForceRandomSides { get; set; } public bool IsForceRandomColors { get; set; } public bool IsForceRandomTeams { get; set; } @@ -41,9 +39,9 @@ public string GetTeamMappingsError() return null; } - public string ToCncnetMessage() => $"{CNCNET_MESSAGE_KEY} {ToString()}"; + public string ToCncnetMessage() => $"{CnCNetCommands.PLAYER_EXTRA_OPTIONS} {ToString()}"; - public string ToLanMessage() => $"{LAN_MESSAGE_KEY} {ToString()}"; + public string ToLanMessage() => $"{LANCommands.PLAYER_EXTRA_OPTIONS} {ToString()}"; public override string ToString() { diff --git a/DXMainClient/Domain/Multiplayer/PlayerHouseInfo.cs b/DXMainClient/Domain/Multiplayer/PlayerHouseInfo.cs index a7493be58..994371fcf 100644 --- a/DXMainClient/Domain/Multiplayer/PlayerHouseInfo.cs +++ b/DXMainClient/Domain/Multiplayer/PlayerHouseInfo.cs @@ -4,7 +4,7 @@ namespace DTAClient.Domain.Multiplayer { - public class PlayerHouseInfo + internal sealed class PlayerHouseInfo { public int SideIndex { get; set; } @@ -18,7 +18,7 @@ public int InternalSideIndex { if (IsSpectator && !string.IsNullOrEmpty(ClientConfiguration.Instance.SpectatorInternalSideIndex)) return int.Parse(ClientConfiguration.Instance.SpectatorInternalSideIndex); - + if (!string.IsNullOrEmpty(ClientConfiguration.Instance.InternalSideIndices)) return Array.ConvertAll(ClientConfiguration.Instance.InternalSideIndices.Split(','), int.Parse)[SideIndex]; @@ -40,8 +40,13 @@ public int InternalSideIndex /// The number of sides in the game. /// Random number generator. /// A bool array that determines which side indexes are disallowed by game options. - public void RandomizeSide(PlayerInfo pInfo, int sideCount, Random random, - bool[] disallowedSideArray, List randomSelectors, int randomCount) + public void RandomizeSide( + PlayerInfo pInfo, + int sideCount, + Random random, + bool[] disallowedSideArray, + List randomSelectors, + int randomCount) { if (pInfo.SideId == 0 || pInfo.SideId == sideCount + randomCount) { @@ -62,7 +67,7 @@ public void RandomizeSide(PlayerInfo pInfo, int sideCount, Random random, int[] randomsides = randomSelectors[pInfo.SideId - 1]; int count = randomsides.Length; int sideId; - + do sideId = randomsides[random.Next(0, count)]; while (disallowedSideArray[sideId]); @@ -81,7 +86,7 @@ public void RandomizeSide(PlayerInfo pInfo, int sideCount, Random random, /// The list of available (un-used) colors. /// The list of all multiplayer colors. /// Random number generator. - public void RandomizeColor(PlayerInfo pInfo, List freeColors, + public void RandomizeColor(PlayerInfo pInfo, List freeColors, List mpColors, Random random) { if (pInfo.ColorId == 0) @@ -114,9 +119,9 @@ public void RandomizeColor(PlayerInfo pInfo, List freeColors, /// True if the player's starting location index exceeds the map's number of starting waypoints, /// otherwise false. public void RandomizeStart( - PlayerInfo pInfo, + PlayerInfo pInfo, Random random, - List freeStartingLocations, + List freeStartingLocations, List takenStartingLocations, bool overrideGameRandomLocations ) diff --git a/DXMainClient/Domain/Multiplayer/PlayerInfo.cs b/DXMainClient/Domain/Multiplayer/PlayerInfo.cs index 43abbdd00..d7fda33bf 100644 --- a/DXMainClient/Domain/Multiplayer/PlayerInfo.cs +++ b/DXMainClient/Domain/Multiplayer/PlayerInfo.cs @@ -1,14 +1,17 @@ -using Rampastring.Tools; -using System; +using System; +using System.Net; +using Rampastring.Tools; namespace DTAClient.Domain.Multiplayer { /// /// A player in the game lobby. /// - public class PlayerInfo + internal class PlayerInfo { - public PlayerInfo() { } + public PlayerInfo() + { + } public PlayerInfo(string name) { @@ -25,17 +28,27 @@ public PlayerInfo(string name, int sideId, int startingLocation, int colorId, in } public string Name { get; set; } + public int SideId { get; set; } + public int StartingLocation { get; set; } + public int ColorId { get; set; } + public int TeamId { get; set; } + public bool Ready { get; set; } + public bool AutoReady { get; set; } + public bool IsAI { get; set; } public bool IsInGame { get; set; } - public virtual string IPAddress { get; set; } = "0.0.0.0"; + + public virtual IPAddress IPAddress { get; set; } = IPAddress.Any; + public int Port { get; set; } + public bool Verified { get; set; } public int Index { get; set; } @@ -73,30 +86,29 @@ public override string ToString() } /// - /// Creates a PlayerInfo instance from a string in a format that matches the + /// Creates a PlayerInfo instance from a string in a format that matches the /// string given by the ToString() method. /// /// The string. /// A PlayerInfo instance, or null if the string format was invalid. public static PlayerInfo FromString(string str) { - var values = str.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + string[] values = str.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); if (values.Length != 8) return null; - var pInfo = new PlayerInfo(); - - pInfo.Name = values[0]; - pInfo.SideId = Conversions.IntFromString(values[1], 0); - pInfo.StartingLocation = Conversions.IntFromString(values[2], 0); - pInfo.ColorId = Conversions.IntFromString(values[3], 0); - pInfo.TeamId = Conversions.IntFromString(values[4], 0); - pInfo.AILevel = Conversions.IntFromString(values[5], 0); - pInfo.IsAI = Conversions.BooleanFromString(values[6], true); - pInfo.Index = Conversions.IntFromString(values[7], 0); - - return pInfo; + return new PlayerInfo + { + Name = values[0], + SideId = Conversions.IntFromString(values[1], 0), + StartingLocation = Conversions.IntFromString(values[2], 0), + ColorId = Conversions.IntFromString(values[3], 0), + TeamId = Conversions.IntFromString(values[4], 0), + AILevel = Conversions.IntFromString(values[5], 0), + IsAI = Conversions.BooleanFromString(values[6], true), + Index = Conversions.IntFromString(values[7], 0) + }; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/SavedGame.cs b/DXMainClient/Domain/SavedGame.cs index 05eeac406..47c300d90 100644 --- a/DXMainClient/Domain/SavedGame.cs +++ b/DXMainClient/Domain/SavedGame.cs @@ -29,7 +29,7 @@ public SavedGame(string fileName) /// private static string GetArchiveName(Stream file) { - var cf = new CompoundFile(file); + using var cf = new CompoundFile(file); var archiveNameBytes = cf.RootStorage.GetStream("Scenario Description").GetData(); var archiveName = System.Text.Encoding.Unicode.GetString(archiveNameBytes); archiveName = archiveName.TrimEnd(new char[] { '\0' }); @@ -56,8 +56,7 @@ public bool ParseInfo() } catch (Exception ex) { - Logger.Log("An error occured while parsing saved game " + FileName + ":" + - ex.Message); + ProgramConstants.LogException(ex, "An error occurred while parsing saved game " + FileName); return false; } } diff --git a/DXMainClient/Online/Channel.cs b/DXMainClient/Online/Channel.cs index 26aa02607..dea8b39d0 100644 --- a/DXMainClient/Online/Channel.cs +++ b/DXMainClient/Online/Channel.cs @@ -2,12 +2,14 @@ using DTAClient.Online.EventArguments; using System; using System.Collections.Generic; +using System.Threading.Tasks; +using DTAClient.Domain.Multiplayer.CnCNet; using DTAClient.DXGUI; using ClientCore.Extensions; namespace DTAClient.Online { - public class Channel : IMessageView + internal sealed class Channel : IMessageView { const int MESSAGE_LIMIT = 1024; @@ -112,8 +114,9 @@ public void AddUser(ChannelUser user) UserAdded?.Invoke(this, new ChannelUserEventArgs(user)); } - public void OnUserJoined(ChannelUser user) + public async ValueTask OnUserJoinedAsync(ChannelUser user) { + await Task.CompletedTask.ConfigureAwait(false); AddUser(user); if (notifyOnUserListChange) @@ -124,7 +127,7 @@ public void OnUserJoined(ChannelUser user) #if !YR if (Persistent && IsChatChannel && user.IRCUser.Name == ProgramConstants.PLAYERNAME) - RequestUserInfo(); + await RequestUserInfoAsync().ConfigureAwait(false); #endif } @@ -254,14 +257,14 @@ public void AddMessage(ChatMessage message) MessageAdded?.Invoke(this, new IRCMessageEventArgs(message)); } - public void SendChatMessage(string message, IRCColor color) + public ValueTask SendChatMessageAsync(string message, IRCColor color) { AddMessage(new ChatMessage(ProgramConstants.PLAYERNAME, color.XnaColor, DateTime.Now, message)); - string colorString = ((char)03).ToString() + color.IrcColorId.ToString("D2"); + string colorString = (char)03 + color.IrcColorId.ToString("D2"); - connection.QueueMessage(QueuedMessageType.CHAT_MESSAGE, 0, - "PRIVMSG " + ChannelName + " :" + colorString + message); + return connection.QueueMessageAsync(QueuedMessageType.CHAT_MESSAGE, 0, + IRCCommands.PRIVMSG + " " + ChannelName + " :" + colorString + message); } /// @@ -271,12 +274,12 @@ public void SendChatMessage(string message, IRCColor color) /// This can be used to help prevent flooding for multiple options that are changed quickly. It allows for a single message /// for multiple changes. /// - public void SendCTCPMessage(string message, QueuedMessageType qmType, int priority, bool replace = false) + public ValueTask SendCTCPMessageAsync(string message, QueuedMessageType qmType, int priority, bool replace = false) { char CTCPChar1 = (char)58; char CTCPChar2 = (char)01; - connection.QueueMessage(qmType, priority, + return connection.QueueMessageAsync(qmType, priority, "NOTICE " + ChannelName + " " + CTCPChar1 + CTCPChar2 + message + CTCPChar2, replace); } @@ -285,9 +288,9 @@ public void SendCTCPMessage(string message, QueuedMessageType qmType, int priori /// /// The name of the user that should be kicked. /// The priority of the message in the send queue. - public void SendKickMessage(string userName, int priority) + public ValueTask SendKickMessageAsync(string userName, int priority) { - connection.QueueMessage(QueuedMessageType.INSTANT_MESSAGE, priority, "KICK " + ChannelName + " " + userName); + return connection.QueueMessageAsync(QueuedMessageType.INSTANT_MESSAGE, priority, IRCCommands.KICK + " " + ChannelName + " " + userName); } /// @@ -295,13 +298,15 @@ public void SendKickMessage(string userName, int priority) /// /// The host that should be banned. /// The priority of the message in the send queue. - public void SendBanMessage(string host, int priority) + public ValueTask SendBanMessageAsync(string host, int priority) { - connection.QueueMessage(QueuedMessageType.INSTANT_MESSAGE, priority, - string.Format("MODE {0} +b *!*@{1}", ChannelName, host)); + return connection.QueueMessageAsync( + QueuedMessageType.INSTANT_MESSAGE, + priority, + FormattableString.Invariant($"{IRCCommands.MODE} {ChannelName} +{IRCChannelModes.BAN} *!*@{host}")); } - public void Join() + public ValueTask JoinAsync() { // Wait a random amount of time before joining to prevent join/part floods if (Persistent) @@ -309,36 +314,35 @@ public void Join() int rn = connection.Rng.Next(1, 10000); if (string.IsNullOrEmpty(Password)) - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, "JOIN " + ChannelName); - else - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, "JOIN " + ChannelName + " " + Password); - } - else - { - if (string.IsNullOrEmpty(Password)) - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, "JOIN " + ChannelName); - else - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, "JOIN " + ChannelName + " " + Password); + return connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, IRCCommands.JOIN + " " + ChannelName); + + return connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, IRCCommands.JOIN + " " + ChannelName + " " + Password); } + + if (string.IsNullOrEmpty(Password)) + return connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, IRCCommands.JOIN + " " + ChannelName); + + return connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, IRCCommands.JOIN + " " + ChannelName + " " + Password); } - public void RequestUserInfo() + public ValueTask RequestUserInfoAsync() { - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, "WHO " + ChannelName); + return connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, "WHO " + ChannelName); } - public void Leave() + public async ValueTask LeaveAsync() { // Wait a random amount of time before joining to prevent join/part floods if (Persistent) { int rn = connection.Rng.Next(1, 10000); - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, "PART " + ChannelName); + await connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, rn, IRCCommands.PART + " " + ChannelName).ConfigureAwait(false); } else { - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 9, "PART " + ChannelName); + await connection.QueueMessageAsync(QueuedMessageType.SYSTEM_MESSAGE, 9, IRCCommands.PART + " " + ChannelName).ConfigureAwait(false); } + ClearUsers(); } diff --git a/DXMainClient/Online/CnCNetGameCheck.cs b/DXMainClient/Online/CnCNetGameCheck.cs index 4951ed9ba..86a634a79 100644 --- a/DXMainClient/Online/CnCNetGameCheck.cs +++ b/DXMainClient/Online/CnCNetGameCheck.cs @@ -1,83 +1,75 @@ -using ClientCore; +using System; +using ClientCore; using System.Diagnostics; +using System.IO; using System.Threading; +using System.Threading.Tasks; namespace DTAClient.Online { - public class CnCNetGameCheck + internal static class CnCNetGameCheck { - private static int REFRESH_INTERVAL = 15000; // 15 seconds + private const int REFRESH_INTERVAL = 15000; // 15 seconds - public void InitializeService(CancellationTokenSource cts) + public static async ValueTask RunServiceAsync(CancellationToken cancellationToken) { - ThreadPool.QueueUserWorkItem(new WaitCallback(RunService), cts); - } - - private void RunService(object tokenObj) - { - var waitHandle = ((CancellationTokenSource)tokenObj).Token.WaitHandle; - - while (true) + while (!cancellationToken.IsCancellationRequested) { - if (waitHandle.WaitOne(REFRESH_INTERVAL)) + try { - // Cancellation signaled - return; + await Task.Delay(REFRESH_INTERVAL, cancellationToken).ConfigureAwait(false); + + CheatEngineWatchEvent(); } - else + catch (OperationCanceledException) { - CheatEngineWatchEvent(); } } } - private void CheatEngineWatchEvent() + private static void CheatEngineWatchEvent() { Process[] processlist = Process.GetProcesses(); + foreach (Process process in processlist) { - try { + try + { if (process.ProcessName.Contains("cheatengine") || process.MainWindowTitle.ToLower().Contains("cheat engine") || - process.MainWindowHandle.ToString().ToLower().Contains("cheat engine") - ) + process.MainWindowHandle.ToString().ToLower().Contains("cheat engine")) { KillGameInstance(); } } - catch { } + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } process.Dispose(); } } - private void KillGameInstance() + private static void KillGameInstance() { - try - { - string gameExecutableName = ClientConfiguration.Instance.GetOperatingSystemVersion() == OSVersion.UNIX ? - ClientConfiguration.Instance.UnixGameExecutableName : - ClientConfiguration.Instance.GetGameExecutableName(); - - gameExecutableName = gameExecutableName.Replace(".exe", ""); + string gameExecutableName = ClientConfiguration.Instance.GetOperatingSystemVersion() == OSVersion.UNIX ? + ClientConfiguration.Instance.UnixGameExecutableName : + ClientConfiguration.Instance.GetGameExecutableName(); - Process[] processlist = Process.GetProcesses(); - foreach (Process process in processlist) + foreach (Process process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(gameExecutableName))) + { + try { - try { - if (process.ProcessName.Contains(gameExecutableName)) - { - process.Kill(); - } - } - catch { } - - process.Dispose(); + process.Kill(); } - } - catch - { + catch (Exception ex) + { + ProgramConstants.LogException(ex); + } + + process.Dispose(); } } } -} +} \ No newline at end of file diff --git a/DXMainClient/Online/CnCNetManager.cs b/DXMainClient/Online/CnCNetManager.cs index 1b3845952..53bb17c25 100644 --- a/DXMainClient/Online/CnCNetManager.cs +++ b/DXMainClient/Online/CnCNetManager.cs @@ -9,6 +9,9 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading.Tasks; +using ClientCore.Extensions; +using DTAClient.Domain.Multiplayer.CnCNet; namespace DTAClient.Online { @@ -16,7 +19,7 @@ namespace DTAClient.Online /// Acts as an interface between the CnCNet connection class /// and the user-interface's classes. /// - public class CnCNetManager : IConnectionManager + internal sealed class CnCNetManager : IConnectionManager { // When implementing IConnectionManager functions, pay special attention // to thread-safety. @@ -25,8 +28,6 @@ public class CnCNetManager : IConnectionManager // UI thread might be reading, use WindowManager.AddCallback to execute a function // on the UI thread instead of modifying the data or raising events directly. - public delegate void UserListDelegate(string channelName, string[] userNames); - public event EventHandler WelcomeMessageReceived; public event EventHandler AwayMessageReceived; public event EventHandler WhoReplyReceived; @@ -79,7 +80,7 @@ public CnCNetManager(WindowManager wm, GameCollection gc, CnCNetUserData cncNetU public Channel MainChannel { get; private set; } - private bool connected = false; + private bool connected; /// /// Gets a value that determines whether the client is @@ -112,13 +113,6 @@ public bool IsAttemptingConnection private WindowManager wm; - private bool disconnect = false; - - public bool IsCnCNetInitialized() - { - return Connection.IsIdSet(); - } - /// /// Factory method for creating a new channel. /// @@ -155,27 +149,19 @@ public IRCColor[] GetIRCColors() return ircChatColors; } - public void LeaveFromChannel(Channel channel) - { - connection.QueueMessage(QueuedMessageType.SYSTEM_MESSAGE, 10, "PART " + channel.ChannelName); - - if (!channel.Persistent) - channels.Remove(channel); - } - public void SetMainChannel(Channel channel) { MainChannel = channel; } - public void SendCustomMessage(QueuedMessage qm) + public ValueTask SendCustomMessageAsync(QueuedMessage qm) { - connection.QueueMessage(qm); + return connection.QueueMessageAsync(qm); } - public void SendWhoIsMessage(string nick) + public ValueTask SendWhoIsMessageAsync(string nick) { - SendCustomMessage(new QueuedMessage($"WHOIS {nick}", QueuedMessageType.WHOIS_MESSAGE, 0)); + return SendCustomMessageAsync(new QueuedMessage($"{IRCCommands.WHOIS} {nick}", QueuedMessageType.WHOIS_MESSAGE, 0)); } public void OnAttemptedServerChanged(string serverName) @@ -183,7 +169,7 @@ public void OnAttemptedServerChanged(string serverName) // AddCallback is necessary for thread-safety; OnAttemptedServerChanged // is called by the networking thread, and AddCallback schedules DoAttemptedServerChanged // to be executed on the main (UI) thread. - wm.AddCallback(new Action(DoAttemptedServerChanged), serverName); + wm.AddCallback(() => DoAttemptedServerChanged(serverName)); } private void DoAttemptedServerChanged(string serverName) @@ -195,7 +181,7 @@ private void DoAttemptedServerChanged(string serverName) public void OnAwayMessageReceived(string userName, string reason) { - wm.AddCallback(new Action(DoAwayMessageReceived), userName, reason); + wm.AddCallback(() => DoAwayMessageReceived(userName, reason)); } private void DoAwayMessageReceived(string userName, string reason) @@ -205,7 +191,7 @@ private void DoAwayMessageReceived(string userName, string reason) public void OnChannelFull(string channelName) { - wm.AddCallback(new Action(DoChannelFull), channelName); + wm.AddCallback(() => DoChannelFull(channelName)); } private void DoChannelFull(string channelName) @@ -218,7 +204,7 @@ private void DoChannelFull(string channelName) public void OnTargetChangeTooFast(string channelName, string message) { - wm.AddCallback(new Action(DoTargetChangeTooFast), channelName, message); + wm.AddCallback(() => DoTargetChangeTooFast(channelName, message)); } private void DoTargetChangeTooFast(string channelName, string message) @@ -231,7 +217,7 @@ private void DoTargetChangeTooFast(string channelName, string message) public void OnChannelInviteOnly(string channelName) { - wm.AddCallback(new Action(DoChannelInviteOnly), channelName); + wm.AddCallback(() => DoChannelInviteOnly(channelName)); } private void DoChannelInviteOnly(string channelName) @@ -244,8 +230,7 @@ private void DoChannelInviteOnly(string channelName) public void OnChannelModesChanged(string userName, string channelName, string modeString, List modeParameters) { - wm.AddCallback(new Action>(DoChannelModesChanged), - userName, channelName, modeString, modeParameters); + wm.AddCallback(() => DoChannelModesChanged(userName, channelName, modeString, modeParameters)); } private void DoChannelModesChanged(string userName, string channelName, string modeString, List modeParameters) @@ -291,7 +276,7 @@ private void ApplyChannelModes(Channel channel, string modeString, List public void OnChannelTopicReceived(string channelName, string topic) { - wm.AddCallback(new Action(DoChannelTopicReceived), channelName, topic); + wm.AddCallback(() => DoChannelTopicReceived(channelName, topic)); } private void DoChannelTopicReceived(string channelName, string topic) @@ -306,13 +291,12 @@ private void DoChannelTopicReceived(string channelName, string topic) public void OnChannelTopicChanged(string userName, string channelName, string topic) { - wm.AddCallback(new Action(DoChannelTopicReceived), channelName, topic); + wm.AddCallback(() => DoChannelTopicReceived(channelName, topic)); } public void OnChatMessageReceived(string receiver, string senderName, string ident, string message) { - wm.AddCallback(new Action(DoChatMessageReceived), - receiver, senderName, ident, message); + wm.AddCallback(() => DoChatMessageReceived(receiver, senderName, ident, message)); } private void DoChatMessageReceived(string receiver, string senderName, string ident, string message) @@ -325,7 +309,7 @@ private void DoChatMessageReceived(string receiver, string senderName, string id Color foreColor; // Handle ACTION - if (message.Contains("ACTION")) + if (message.Contains(IRCCommands.PRIVMSG_ACTION)) { message = message.Remove(0, 7); message = "====> " + senderName + " " + message; @@ -364,7 +348,7 @@ private void DoChatMessageReceived(string receiver, string senderName, string id foreColor = cDefaultChatColor; } - if (message.Length > 1 && message[message.Length - 1] == '\u001f') + if (message.Length > 1 && message[^1] == '\u001f') message = message.Remove(message.Length - 1); ChannelUser user = channel.Users.Find(senderName); @@ -375,8 +359,7 @@ private void DoChatMessageReceived(string receiver, string senderName, string id public void OnCTCPParsed(string channelName, string userName, string message) { - wm.AddCallback(new Action(DoCTCPParsed), - channelName, userName, message); + wm.AddCallback(() => DoCTCPParsed(channelName, userName, message)); } private void DoCTCPParsed(string channelName, string userName, string message) @@ -402,7 +385,7 @@ private void DoCTCPParsed(string channelName, string userName, string message) public void OnConnectAttemptFailed() { - wm.AddCallback(new Action(DoConnectAttemptFailed), null); + wm.AddCallback(DoConnectAttemptFailed); } private void DoConnectAttemptFailed() @@ -414,7 +397,7 @@ private void DoConnectAttemptFailed() public void OnConnected() { - wm.AddCallback(new Action(DoConnected), null); + wm.AddCallback(DoConnected); } private void DoConnected() @@ -427,10 +410,9 @@ private void DoConnected() /// /// Called when the connection has got cut un-intentionally. /// - /// public void OnConnectionLost(string reason) { - wm.AddCallback(new Action(DoConnectionLost), reason); + wm.AddCallback(() => DoConnectionLost(reason)); } private void DoConnectionLost(string reason) @@ -459,18 +441,14 @@ private void DoConnectionLost(string reason) /// /// Disconnects from CnCNet. /// - public void Disconnect() - { - connection.Disconnect(); - disconnect = true; - } + public ValueTask DisconnectAsync() + => connection.DisconnectAsync(); /// /// Connects to CnCNet. /// public void Connect() { - disconnect = false; MainChannel.AddMessage(new ChatMessage("Connecting to CnCNet...".L10N("Client:Main:ConnectingToCncNet"))); connection.ConnectAsync(); } @@ -480,7 +458,7 @@ public void Connect() /// public void OnDisconnected() { - wm.AddCallback(new Action(DoDisconnected), null); + wm.AddCallback(DoDisconnected); } private void DoDisconnected() @@ -513,7 +491,7 @@ public void OnErrorReceived(string errorMessage) public void OnGenericServerMessageReceived(string message) { - wm.AddCallback(new Action(DoGenericServerMessageReceived), message); + wm.AddCallback(() => DoGenericServerMessageReceived(message)); } private void DoGenericServerMessageReceived(string message) @@ -523,7 +501,7 @@ private void DoGenericServerMessageReceived(string message) public void OnIncorrectChannelPassword(string channelName) { - wm.AddCallback(new Action(DoIncorrectChannelPassword), channelName); + wm.AddCallback(() => DoIncorrectChannelPassword(channelName)); } private void DoIncorrectChannelPassword(string channelName) @@ -540,8 +518,7 @@ public void OnNoticeMessageParsed(string notice, string userName) public void OnPrivateMessageReceived(string sender, string message) { - wm.AddCallback(new Action(DoPrivateMessageReceived), - sender, message); + wm.AddCallback(() => DoPrivateMessageReceived(sender, message)); } private void DoPrivateMessageReceived(string sender, string message) @@ -553,7 +530,7 @@ private void DoPrivateMessageReceived(string sender, string message) public void OnReconnectAttempt() { - wm.AddCallback(new Action(DoReconnectAttempt), null); + wm.AddCallback(DoReconnectAttempt); } private void DoReconnectAttempt() @@ -567,11 +544,10 @@ private void DoReconnectAttempt() public void OnUserJoinedChannel(string channelName, string host, string userName, string ident) { - wm.AddCallback(new Action(DoUserJoinedChannel), - channelName, host, userName, ident); + wm.AddCallback(() => DoUserJoinedChannelAsync(channelName, host, userName, ident).HandleTask()); } - private void DoUserJoinedChannel(string channelName, string host, string userName, string userAddress) + private async ValueTask DoUserJoinedChannelAsync(string channelName, string host, string userName, string userAddress) { Channel channel = FindChannel(channelName); @@ -620,9 +596,7 @@ private void DoUserJoinedChannel(string channelName, string host, string userNam channelUser.IsFriend = cncNetUserData.IsFriend(channelUser.IRCUser.Name); ircUser.Channels.Add(channelName); - channel.OnUserJoined(channelUser); - - //UserJoinedChannel?.Invoke(this, new ChannelUserEventArgs(channelName, userName)); + await channel.OnUserJoinedAsync(channelUser).ConfigureAwait(false); } private void AddUserToGlobalUserList(IRCUser user) @@ -634,8 +608,7 @@ private void AddUserToGlobalUserList(IRCUser user) public void OnUserKicked(string channelName, string userName) { - wm.AddCallback(new Action(DoUserKicked), - channelName, userName); + wm.AddCallback(() => DoUserKicked(channelName, userName)); } private void DoUserKicked(string channelName, string userName) @@ -666,8 +639,7 @@ private void DoUserKicked(string channelName, string userName) public void OnUserLeftChannel(string channelName, string userName) { - wm.AddCallback(new Action(DoUserLeftChannel), - channelName, userName); + wm.AddCallback(() => DoUserLeftChannel(channelName, userName)); } private void DoUserLeftChannel(string channelName, string userName) @@ -722,8 +694,7 @@ public void RemoveChannelFromUser(string userName, string channelName) public void OnUserListReceived(string channelName, string[] userList) { - wm.AddCallback(new UserListDelegate(DoUserListReceived), - channelName, userList); + wm.AddCallback(() => DoUserListReceived(channelName, userList)); } private void DoUserListReceived(string channelName, string[] userList) @@ -743,10 +714,10 @@ private void DoUserListReceived(string channelName, string[] userList) if (userName.StartsWith("@")) { isAdmin = true; - name = userName.Substring(1); + name = userName[1..]; } else if (userName.StartsWith("+")) - name = userName.Substring(1); + name = userName[1..]; // Check if we already know the IRC user from another channel IRCUser ircUser = UserList.Find(u => u.Name == name); @@ -774,7 +745,7 @@ private void DoUserListReceived(string channelName, string[] userList) public void OnUserQuitIRC(string userName) { - wm.AddCallback(new Action(DoUserQuitIRC), userName); + wm.AddCallback(() => DoUserQuitIRC(userName)); } private void DoUserQuitIRC(string userName) @@ -792,10 +763,9 @@ private void DoUserQuitIRC(string userName) public void OnWelcomeMessageReceived(string message) { - wm.AddCallback(new Action(DoWelcomeMessageReceived), message); + wm.AddCallback(() => DoWelcomeMessageReceived(message)); } - /// /// Finds a channel with the specified internal name, case-insensitively. /// @@ -823,8 +793,7 @@ private void DoWelcomeMessageReceived(string message) public void OnWhoReplyReceived(string ident, string hostName, string userName, string extraInfo) { - wm.AddCallback(new Action(DoWhoReplyReceived), - ident, hostName, userName, extraInfo); + wm.AddCallback(() => DoWhoReplyReceived(ident, hostName, userName, extraInfo)); } private void DoWhoReplyReceived(string ident, string hostName, string userName, string extraInfo) @@ -859,14 +828,9 @@ private void DoWhoReplyReceived(string ident, string hostName, string userName, } } - public bool GetDisconnectStatus() - { - return disconnect; - } - public void OnNameAlreadyInUse() { - wm.AddCallback(new Action(DoNameAlreadyInUse), null); + wm.AddCallback(() => DoNameAlreadyInUseAsync().HandleTask()); } /// @@ -874,7 +838,7 @@ public void OnNameAlreadyInUse() /// IRC user. Adds additional underscores to the name or replaces existing /// characters with underscores. /// - private void DoNameAlreadyInUse() + private async ValueTask DoNameAlreadyInUseAsync() { var charList = ProgramConstants.PLAYERNAME.ToList(); int maxNameLength = ClientConfiguration.Instance.MaxNameLength; @@ -890,7 +854,7 @@ private void DoNameAlreadyInUse() MainChannel.AddMessage(new ChatMessage(Color.White, "Your nickname is invalid or already in use. Please change your nickname in the login screen.".L10N("Client:Main:PickAnotherNickName"))); UserINISettings.Instance.SkipConnectDialog.Value = false; - Disconnect(); + await DisconnectAsync().ConfigureAwait(false); return; } @@ -902,15 +866,15 @@ private void DoNameAlreadyInUse() sb.Append(c); MainChannel.AddMessage(new ChatMessage(Color.White, - string.Format("Your name is already in use. Retrying with {0}...".L10N("Client:Main:NameInUseRetry"), sb.ToString()))); + string.Format("Your name is already in use. Retrying with {0}...".L10N("Client:Main:NameInUseRetry"), sb))); ProgramConstants.PLAYERNAME = sb.ToString(); - connection.ChangeNickname(); + await connection.ChangeNicknameAsync().ConfigureAwait(false); } public void OnBannedFromChannel(string channelName) { - wm.AddCallback(new Action(DoBannedFromChannel), channelName); + wm.AddCallback(() => DoBannedFromChannel(channelName)); } private void DoBannedFromChannel(string channelName) @@ -919,7 +883,7 @@ private void DoBannedFromChannel(string channelName) } public void OnUserNicknameChange(string oldNickname, string newNickname) - => wm.AddCallback(new Action(DoUserNicknameChange), oldNickname, newNickname); + => wm.AddCallback(() => DoUserNicknameChange(oldNickname, newNickname)); private void DoUserNicknameChange(string oldNickname, string newNickname) { @@ -956,17 +920,7 @@ public UserEventArgs(IRCUser ircUser) User = ircUser; } - public IRCUser User { get; private set; } - } - - public class IndexEventArgs : EventArgs - { - public IndexEventArgs(int index) - { - Index = index; - } - - public int Index { get; private set; } + public IRCUser User { get; } } public class UserNameChangedEventArgs : EventArgs @@ -980,4 +934,4 @@ public UserNameChangedEventArgs(string oldUserName, IRCUser user) public string OldUserName { get; } public IRCUser User { get; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Online/CnCNetUserData.cs b/DXMainClient/Online/CnCNetUserData.cs index ea6f34142..9d782954a 100644 --- a/DXMainClient/Online/CnCNetUserData.cs +++ b/DXMainClient/Online/CnCNetUserData.cs @@ -6,6 +6,8 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading.Tasks; +using ClientCore.Extensions; namespace DTAClient.Online { @@ -22,71 +24,81 @@ public sealed class CnCNetUserData /// directly you have to also invoke UserFriendToggled event handler for every /// user name added or removed. /// - public List FriendList { get; private set; } = new(); + public List FriendList { get; private set; } /// /// A list which contains idents of ignored users. If you manipulate this list /// directly you have to also invoke UserIgnoreToggled event handler for every /// user ident added or removed. /// - public List IgnoreList { get; private set; } = new(); + public List IgnoreList { get; private set; } /// /// A list which contains names of players from recent games. /// - public List RecentList { get; private set; } = new(); + public List RecentList { get; private set; } public event EventHandler UserFriendToggled; public event EventHandler UserIgnoreToggled; public CnCNetUserData(WindowManager windowManager) { - LoadFriendList(); - LoadIgnoreList(); - LoadRecentPlayerList(); - windowManager.GameClosing += WindowManager_GameClosing; } - private static List LoadTextList(string path) + public async ValueTask InitializeAsync() + { + FriendList = await LoadTextListAsync(FRIEND_LIST_PATH).ConfigureAwait(false); + IgnoreList = await LoadTextListAsync(IGNORE_LIST_PATH).ConfigureAwait(false); + RecentList = await LoadJsonListAsync(RECENT_LIST_PATH).ConfigureAwait(false); + } + + private static async ValueTask> LoadTextListAsync(string path) { try { FileInfo listFile = SafePath.GetFile(ProgramConstants.GamePath, path); if (listFile.Exists) - return File.ReadAllLines(listFile.FullName).ToList(); + return (await File.ReadAllLinesAsync(listFile.FullName).ConfigureAwait(false)).ToList(); Logger.Log($"Loading {path} failed! File does not exist."); return new(); } - catch + catch (Exception ex) { - Logger.Log($"Loading {path} list failed!"); + ProgramConstants.LogException(ex, $"Loading {path} list failed!"); return new(); } } - private static List LoadJsonList(string path) + private static async ValueTask> LoadJsonListAsync(string path) { try { FileInfo listFile = SafePath.GetFile(ProgramConstants.GamePath, path); if (listFile.Exists) - return JsonSerializer.Deserialize>(File.ReadAllText(listFile.FullName)) ?? new List(); + { + FileStream fileStream = File.OpenRead(listFile.FullName); + + await using (fileStream.ConfigureAwait(false)) + { + return (await JsonSerializer.DeserializeAsync>(fileStream).ConfigureAwait(false)) ?? new List(); + } + } Logger.Log($"Loading {path} failed! File does not exist."); return new(); } - catch + catch (Exception ex) { - Logger.Log($"Loading {path} list failed!"); + ProgramConstants.LogException(ex, $"Loading {path} list failed!"); return new(); } } - private static void SaveTextList(string path, List textList) + private static async ValueTask SaveTextListAsync(string path, List textList) { Logger.Log($"Saving {path}."); @@ -95,15 +107,15 @@ private static void SaveTextList(string path, List textList) FileInfo listFileInfo = SafePath.GetFile(ProgramConstants.GamePath, path); listFileInfo.Delete(); - File.WriteAllLines(listFileInfo.FullName, textList.ToArray()); + await File.WriteAllLinesAsync(listFileInfo.FullName, textList).ConfigureAwait(false); } catch (Exception ex) { - Logger.Log($"Saving {path} failed! Error message: " + ex.Message); + ProgramConstants.LogException(ex, $"Saving {path} failed!"); } } - private static void SaveJsonList(string path, IReadOnlyCollection jsonList) + private static async ValueTask SaveJsonListAsync(string path, IReadOnlyCollection jsonList) { Logger.Log($"Saving {path}."); @@ -112,11 +124,17 @@ private static void SaveJsonList(string path, IReadOnlyCollection jsonList FileInfo listFileInfo = SafePath.GetFile(ProgramConstants.GamePath, path); listFileInfo.Delete(); - File.WriteAllText(listFileInfo.FullName, JsonSerializer.Serialize(jsonList)); + + FileStream fileStream = listFileInfo.OpenWrite(); + + await using (fileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(fileStream, jsonList).ConfigureAwait(false); + } } catch (Exception ex) { - Logger.Log($"Saving {path} failed! Error message: " + ex.Message); + ProgramConstants.LogException(ex, $"Saving {path} failed!"); } } @@ -131,25 +149,13 @@ private static void Toggle(string value, ICollection list) list.Add(value); } - private void LoadFriendList() => FriendList = LoadTextList(FRIEND_LIST_PATH); - - private void LoadIgnoreList() => IgnoreList = LoadTextList(IGNORE_LIST_PATH); - - private void LoadRecentPlayerList() => RecentList = LoadJsonList(RECENT_LIST_PATH); - - private void WindowManager_GameClosing(object sender, EventArgs e) => Save(); - - private void SaveFriends() => SaveTextList(FRIEND_LIST_PATH, FriendList); - - private void SaveIgnoreList() => SaveTextList(IGNORE_LIST_PATH, IgnoreList); - - private void SaveRecentList() => SaveJsonList(RECENT_LIST_PATH, RecentList); + private void WindowManager_GameClosing(object sender, EventArgs e) => SaveAsync().HandleTask(); - private void Save() + private async ValueTask SaveAsync() { - SaveFriends(); - SaveIgnoreList(); - SaveRecentList(); + await SaveTextListAsync(FRIEND_LIST_PATH, FriendList).ConfigureAwait(false); + await SaveTextListAsync(IGNORE_LIST_PATH, IgnoreList).ConfigureAwait(false); + await SaveJsonListAsync(RECENT_LIST_PATH, RecentList).ConfigureAwait(false); } /// diff --git a/DXMainClient/Online/Connection.cs b/DXMainClient/Online/Connection.cs index ba1341bab..74be6ab15 100644 --- a/DXMainClient/Online/Connection.cs +++ b/DXMainClient/Online/Connection.cs @@ -1,9 +1,6 @@ -using ClientCore; -using ClientCore.Extensions; -using Rampastring.Tools; -using System; +using System; +using System.Buffers; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net; using System.Net.NetworkInformation; @@ -11,128 +8,91 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using ClientCore; +using ClientCore.Extensions; +using DTAClient.Domain.Multiplayer; +using DTAClient.Domain.Multiplayer.CnCNet; +using Rampastring.Tools; namespace DTAClient.Online { /// /// The CnCNet connection handler. /// - public class Connection + internal sealed class Connection { private const int MAX_RECONNECT_COUNT = 8; private const int RECONNECT_WAIT_DELAY = 4000; private const int ID_LENGTH = 9; private const int MAXIMUM_LATENCY = 400; + private const int SendTimeout = 1000; + private const int ConnectTimeout = 3000; + private const int PingInterval = 120000; public Connection(IConnectionManager connectionManager) { this.connectionManager = connectionManager; } - IConnectionManager connectionManager; + private readonly IConnectionManager connectionManager; /// /// The list of CnCNet / GameSurge IRC servers to connect to. /// private static readonly IList Servers = new List { - new Server("Burstfire.UK.EU.GameSurge.net", "GameSurge London, UK", new int[3] { 6667, 6668, 7000 }), - new Server("ColoCrossing.IL.US.GameSurge.net", "GameSurge Chicago, IL", new int[5] { 6660, 6666, 6667, 6668, 6669 }), - new Server("Gameservers.NJ.US.GameSurge.net", "GameSurge Newark, NJ", new int[7] { 6665, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("Krypt.CA.US.GameSurge.net", "GameSurge Santa Ana, CA", new int[4] { 6666, 6667, 6668, 6669 }), - new Server("NuclearFallout.WA.US.GameSurge.net", "GameSurge Seattle, WA", new int[2] { 6667, 5960 }), - new Server("Portlane.SE.EU.GameSurge.net", "GameSurge Stockholm, Sweden", new int[5] { 6660, 6666, 6667, 6668, 6669 }), - new Server("Prothid.NY.US.GameSurge.Net", "GameSurge NYC, NY", new int[7] { 5960, 6660, 6666, 6667, 6668, 6669, 6697 }), - new Server("TAL.DE.EU.GameSurge.net", "GameSurge Wuppertal, Germany", new int[5] { 6660, 6666, 6667, 6668, 6669 }), - new Server("208.167.237.120", "GameSurge IP 208.167.237.120", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("192.223.27.109", "GameSurge IP 192.223.27.109", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("108.174.48.100", "GameSurge IP 108.174.48.100", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("208.146.35.105", "GameSurge IP 208.146.35.105", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("195.8.250.180", "GameSurge IP 195.8.250.180", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("91.217.189.76", "GameSurge IP 91.217.189.76", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("195.68.206.250", "GameSurge IP 195.68.206.250", new int[7] { 6660, 6666, 6667, 6668, 6669, 7000, 8080 }), - new Server("irc.gamesurge.net", "GameSurge", new int[1] { 6667 }), + new("Burstfire.UK.EU.GameSurge.net", "GameSurge London, UK", new[] { 6667, 6668, 7000 }), + new("VortexServers.IL.US.GameSurge.net", "GameSurge Chicago, IL", new[] { 6660, 6666, 6667, 6668, 6669 }), + new("Gameservers.NJ.US.GameSurge.net", "GameSurge Newark, NJ", new[] { 6665, 6666, 6667, 6668, 6669, 7000, 8080 }), + new("Krypt.CA.US.GameSurge.net", "GameSurge Santa Ana, CA", new[] { 6666, 6667, 6668, 6669 }), + new("NuclearFallout.WA.US.GameSurge.net", "GameSurge Seattle, WA", new[] { 6667, 5960 }), + new("Stockholm.SE.EU.GameSurge.net", "GameSurge Stockholm, Sweden", new[] { 6660, 6666, 6667, 6668, 6669 }), + new("Prothid.NY.US.GameSurge.Net", "GameSurge NYC, NY", new[] { 5960, 6660, 6666, 6667, 6668, 6669 }), + new("TAL.DE.EU.GameSurge.net", "GameSurge Wuppertal, Germany", new[] { 6660, 6666, 6667, 6668, 6669 }), + new("irc.gamesurge.net", "GameSurge", new[] { 6667 }) }.AsReadOnly(); - bool _isConnected = false; - public bool IsConnected - { - get { return _isConnected; } - } + private bool IsConnected { get; set; } - bool _attemptingConnection = false; - public bool AttemptingConnection - { - get { return _attemptingConnection; } - } + public bool AttemptingConnection { get; private set; } - Random _rng = new Random(); - public Random Rng - { - get { return _rng; } - } + public Random Rng { get; } = new(); - private List MessageQueue = new List(); - private TimeSpan MessageQueueDelay; + private readonly List messageQueue = new(); + private TimeSpan messageQueueDelay; - private NetworkStream serverStream; - private TcpClient tcpClient; + private Socket socket; - volatile int reconnectCount = 0; + private volatile int reconnectCount; - private volatile bool connectionCut = false; - private volatile bool welcomeMessageReceived = false; - private volatile bool sendQueueExited = false; - bool _disconnect = false; - private bool disconnect - { - get - { - lock (locker) - return _disconnect; - } - set - { - lock (locker) - _disconnect = value; - } - } + private volatile bool connectionCut; + private volatile bool welcomeMessageReceived; + private volatile bool sendQueueExited; private string overMessage; - private readonly Encoding encoding = Encoding.UTF8; - /// /// A list of server IPs that have dropped our connection. /// The client skips these servers when attempting to re-connect, to /// prevent a server that first accepts a connection and then drops it /// right afterwards from preventing online play. /// - private readonly List failedServerIPs = new List(); + private readonly List failedServerIPs = new(); private volatile string currentConnectedServerIP; - private static readonly object locker = new object(); - private static readonly object messageQueueLocker = new object(); + private static readonly SemaphoreSlim messageQueueLocker = new(1, 1); - private static bool idSet = false; private static string systemId; - private static readonly object idLocker = new object(); + private static readonly object idLocker = new(); + private CancellationTokenSource connectionCancellationTokenSource; + private CancellationTokenSource sendQueueCancellationTokenSource; public static void SetId(string id) { lock (idLocker) { int maxLength = ID_LENGTH - (ClientConfiguration.Instance.LocalGame.Length + 1); - systemId = Utilities.CalculateSHA1ForString(id).Substring(0, maxLength); - idSet = true; - } - } - - public static bool IsIdSet() - { - lock (idLocker) - { - return idSet; + systemId = Utilities.CalculateSHA1ForString(id)[..maxLength]; } } @@ -141,123 +101,143 @@ public static bool IsIdSet() /// public void ConnectAsync() { - if (_isConnected) - throw new InvalidOperationException("The client is already connected!"); + if (IsConnected) + throw new InvalidOperationException("The client is already connected!".L10N("Client:Main:ClientAlreadyConnected")); - if (_attemptingConnection) + if (AttemptingConnection) return; // Maybe we should throw in this case as well? welcomeMessageReceived = false; connectionCut = false; - _attemptingConnection = true; - disconnect = false; + AttemptingConnection = true; + + messageQueueDelay = TimeSpan.FromMilliseconds(ClientConfiguration.Instance.SendSleep); + + connectionCancellationTokenSource?.Dispose(); - MessageQueueDelay = TimeSpan.FromMilliseconds(ClientConfiguration.Instance.SendSleep); + connectionCancellationTokenSource = new CancellationTokenSource(); - Thread connection = new Thread(ConnectToServer); - connection.Start(); + ConnectToServerAsync(connectionCancellationTokenSource.Token).HandleTask(); } /// /// Attempts to connect to CnCNet. /// - private void ConnectToServer() + private async ValueTask ConnectToServerAsync(CancellationToken cancellationToken) { - IList sortedServerList = GetServerListSortedByLatency(); - - foreach (Server server in sortedServerList) + try { - try + IList sortedServerList = await GetServerListSortedByLatencyAsync().ConfigureAwait(false); + + foreach (Server server in sortedServerList) { - for (int i = 0; i < server.Ports.Length; i++) + try { - connectionManager.OnAttemptedServerChanged(server.Name); + foreach (int port in server.Ports) + { + connectionManager.OnAttemptedServerChanged(server.Name); - TcpClient client = new TcpClient(AddressFamily.InterNetwork); - var result = client.BeginConnect(server.Host, server.Ports[i], null, null); - result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(3), false); + var client = new Socket(SocketType.Stream, ProtocolType.Tcp); + using var timeoutCancellationTokenSource = new CancellationTokenSource(ConnectTimeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken); - Logger.Log("Attempting connection to " + server.Host + ":" + server.Ports[i]); + Logger.Log("Attempting connection to " + server.Host + ":" + port); - if (!client.Connected) - { - Logger.Log("Connecting to " + server.Host + " port " + server.Ports[i] + " timed out!"); - continue; // Start all over again, using the next port - } + try + { + await client.ConnectAsync( + new IPEndPoint(IPAddress.Parse(server.Host), port), + linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.Token.IsCancellationRequested) + { + Logger.Log("Connecting to " + server.Host + " port " + port + " timed out!"); + continue; // Start all over again, using the next port + } + + Logger.Log("Successfully connected to " + server.Host + " on port " + port); - Logger.Log("Succesfully connected to " + server.Host + " on port " + server.Ports[i]); - client.EndConnect(result); + IsConnected = true; + AttemptingConnection = false; - _isConnected = true; - _attemptingConnection = false; + connectionManager.OnConnected(); + sendQueueCancellationTokenSource?.Dispose(); - connectionManager.OnConnected(); + sendQueueCancellationTokenSource = new CancellationTokenSource(); - Thread sendQueueHandler = new Thread(RunSendQueue); - sendQueueHandler.Start(); + RunSendQueueAsync(sendQueueCancellationTokenSource.Token).HandleTask(); - tcpClient = client; - serverStream = tcpClient.GetStream(); - serverStream.ReadTimeout = 1000; + if (socket?.Connected ?? false) + socket.Shutdown(SocketShutdown.Both); - currentConnectedServerIP = server.Host; - HandleComm(); - return; + socket?.Close(); + socket = client; + + currentConnectedServerIP = server.Host; + await HandleCommAsync(cancellationToken).ConfigureAwait(false); + return; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + ProgramConstants.LogException(ex, "Unable to connect to the server."); } } - catch (Exception ex) - { - Logger.Log("Unable to connect to the server. " + ex.Message); - } - } - Logger.Log("Connecting to CnCNet failed!"); - // Clear the failed server list in case connecting to all servers has failed - failedServerIPs.Clear(); - _attemptingConnection = false; - connectionManager.OnConnectAttemptFailed(); + Logger.Log("Connecting to CnCNet failed!"); + // Clear the failed server list in case connecting to all servers has failed + failedServerIPs.Clear(); + AttemptingConnection = false; + connectionManager.OnConnectAttemptFailed(); + } + catch (OperationCanceledException) + { + } } - private void HandleComm() + private async ValueTask HandleCommAsync(CancellationToken cancellationToken) { int errorTimes = 0; - byte[] message = new byte[1024]; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(1024); + Memory message = memoryOwner.Memory[..1024]; + + await RegisterAsync().ConfigureAwait(false); - Register(); + var timer = new System.Timers.Timer(PingInterval) + { + Enabled = true + }; - Timer timer = new Timer(AutoPing, null, 30000, 120000); + timer.Elapsed += (_, _) => AutoPingAsync().HandleTask(); connectionCut = true; - while (true) + while (!cancellationToken.IsCancellationRequested) { - if (connectionManager.GetDisconnectStatus()) - { - connectionManager.OnDisconnected(); - connectionCut = false; // This disconnect is intentional - break; - } - - if (!serverStream.DataAvailable) - { - Thread.Sleep(10); - continue; - } - int bytesRead; try { - bytesRead = serverStream.Read(message, 0, 1024); + bytesRead = await socket.ReceiveAsync(message, SocketFlags.None, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { - Logger.Log("Disconnected from CnCNet due to a socket error. Message: " + ex.Message); + ProgramConstants.LogException(ex, "Disconnected from CnCNet due to a socket error."); + errorTimes++; if (errorTimes > MAX_RECONNECT_COUNT) { const string errorMessage = "Disconnected from CnCNet after reaching the maximum number of connection retries."; + Logger.Log(errorMessage); failedServerIPs.Add(currentConnectedServerIP); connectionManager.OnConnectionLost(errorMessage.L10N("Client:Main:ClientDisconnectedAfterRetries")); @@ -269,24 +249,38 @@ private void HandleComm() errorTimes = 0; - // A message has been succesfully received - string msg = encoding.GetString(message, 0, bytesRead); + // A message has been successfully received + string msg = Encoding.UTF8.GetString(message.Span[..bytesRead]); + +#if !DEBUG Logger.Log("Message received: " + msg); +#endif + await HandleMessageAsync(msg).ConfigureAwait(false); + + timer.Interval = 30000; + } + + if (cancellationToken.IsCancellationRequested) + { + connectionManager.OnDisconnected(); - HandleMessage(msg); - timer.Change(30000, 30000); + connectionCut = false; // This disconnect is intentional } - timer.Change(Timeout.Infinite, Timeout.Infinite); + timer.Enabled = false; + timer.Dispose(); - _isConnected = false; - disconnect = false; + IsConnected = false; if (connectionCut) { + sendQueueCancellationTokenSource.Cancel(); + while (!sendQueueExited) - Thread.Sleep(100); + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } reconnectCount++; @@ -296,7 +290,7 @@ private void HandleComm() return; } - Thread.Sleep(RECONNECT_WAIT_DELAY); + await Task.Delay(RECONNECT_WAIT_DELAY, cancellationToken).ConfigureAwait(false); if (IsConnected || AttemptingConnection) { @@ -315,179 +309,157 @@ private void HandleComm() /// Servers that did not respond to ICMP messages in time will be placed at the end of the list. /// /// A list of Lobby servers sorted by latency. - private IList GetServerListSortedByLatency() + private async ValueTask> GetServerListSortedByLatencyAsync() { // Resolve the hostnames. - ICollection>>> - dnsTasks = new List>>>(Servers.Count); - - foreach (Server server in Servers) - { - string serverHostnameOrIPAddress = server.Host; - string serverName = server.Name; - int[] serverPorts = server.Ports; - - Task>> dnsTask = new Task>>(() => - { - Logger.Log($"Attempting to DNS resolve {serverName} ({serverHostnameOrIPAddress})."); - ICollection> _serverInfos = new List>(); - - try - { - // If hostNameOrAddress is an IP address, this address is returned without querying the DNS server. - IEnumerable serverIPAddresses = Dns.GetHostAddresses(serverHostnameOrIPAddress) - .Where(IPAddress => IPAddress.AddressFamily == AddressFamily.InterNetwork); - - Logger.Log($"DNS resolved {serverName} ({serverHostnameOrIPAddress}): " + - $"{string.Join(", ", serverIPAddresses.Select(item => item.ToString()))}"); - - // Store each IPAddress in a different tuple. - foreach (IPAddress serverIPAddress in serverIPAddresses) - { - _serverInfos.Add(new Tuple(serverIPAddress, serverName, serverPorts)); - } - } - catch (SocketException ex) - { - Logger.Log($"Caught an exception when DNS resolving {serverName} ({serverHostnameOrIPAddress}) Lobby server: {ex.Message}"); - } - - return _serverInfos; - }); - - dnsTask.Start(); - dnsTasks.Add(dnsTask); - } - - Task.WaitAll(dnsTasks.ToArray()); + IEnumerable<(IPAddress IpAddress, string Name, int[] Ports)>[] servers = + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(Servers.Select(ResolveServerAsync)).ConfigureAwait(false); // Group the tuples by IPAddress to merge duplicate servers. - IEnumerable>> - serverInfosGroupedByIPAddress = dnsTasks.SelectMany(dnsTask => dnsTask.Result) // Tuple - .GroupBy( - serverInfo => serverInfo.Item1, // IPAddress - serverInfo => new Tuple( - serverInfo.Item2, // serverName - serverInfo.Item3 // serverPorts - ) - ); + IEnumerable> serverInfosGroupedByIPAddress = servers + .SelectMany(server => server) + .GroupBy(serverInfo => serverInfo.IpAddress, serverInfo => (serverInfo.Name, serverInfo.Ports)); + bool hasIPv6Internet = NetworkHelper.HasIPv6Internet(); + bool hasIPv4Internet = NetworkHelper.HasIPv4Internet(); // Process each group: // 1. Get IPAddress. // 2. Concatenate serverNames. // 3. Remove duplicate ports. // 4. Construct and return a tuple that contains the IPAddress, concatenated serverNames and unique ports. - IEnumerable> serverInfos = serverInfosGroupedByIPAddress.Select(serverInfoGroup => + (IPAddress IpAddress, string Name, int[] Ports)[] serverInfos = serverInfosGroupedByIPAddress.Select(serverInfoGroup => { IPAddress ipAddress = serverInfoGroup.Key; - string serverNames = string.Join(", ", serverInfoGroup.Select(serverInfo => serverInfo.Item1)); - int[] serverPorts = serverInfoGroup.SelectMany(serverInfo => serverInfo.Item2).Distinct().ToArray(); + string serverNames = string.Join(", ", serverInfoGroup.Where(serverInfo => !"GameSurge".Equals(serverInfo.Name)) + .Select(serverInfo => serverInfo.Name)); + int[] serverPorts = serverInfoGroup.SelectMany(serverInfo => serverInfo.Ports).Distinct().ToArray(); - return new Tuple(ipAddress, serverNames, serverPorts); - }); + return (ipAddress, serverNames, serverPorts); + }). + Where(q => (q.ipAddress.AddressFamily is AddressFamily.InterNetworkV6 && hasIPv6Internet) + || (q.ipAddress.AddressFamily is AddressFamily.InterNetwork && hasIPv4Internet)) + .ToArray(); // Do logging. - foreach (Tuple serverInfo in serverInfos) + foreach ((IPAddress ipAddress, string name, int[] ports) in serverInfos) { - string serverIPAddress = serverInfo.Item1.ToString(); - string serverNames = string.Join(", ", serverInfo.Item2.ToString()); - string serverPorts = string.Join(", ", serverInfo.Item3.Select(port => port.ToString())); + string serverIPAddress = ipAddress.ToString(); + string serverNames = string.Join(", ", name); + string serverPorts = string.Join(", ", ports.Select(port => port.ToString())); Logger.Log($"Got a Lobby server. IP: {serverIPAddress}; Name: {serverNames}; Ports: {serverPorts}."); } - Logger.Log($"The number of Lobby servers is {serverInfos.Count()}."); + Logger.Log($"The number of Lobby servers is {serverInfos.Length}."); // Test the latency. - ICollection>> pingTasks = new List>>(serverInfos.Count()); - - foreach (Tuple serverInfo in serverInfos) + foreach ((IPAddress ipAddress, string name, int[] _) in serverInfos.Where(q => failedServerIPs.Contains(q.IpAddress.ToString()))) { - IPAddress serverIPAddress = serverInfo.Item1; - string serverNames = serverInfo.Item2; - int[] serverPorts = serverInfo.Item3; + Logger.Log($"Skipped a failed server {name} ({ipAddress})."); + } - if (failedServerIPs.Contains(serverIPAddress.ToString())) - { - Logger.Log($"Skipped a failed server {serverNames} ({serverIPAddress})."); - continue; - } + (Server Server, IPAddress IpAddress, long Result)[] serverAndLatencyResults = + await ClientCore.Extensions.TaskExtensions.WhenAllSafe(serverInfos.Where(q => !failedServerIPs.Contains(q.IpAddress.ToString())).Select(PingServerAsync)).ConfigureAwait(false); + + // Sort the servers by AddressFamily & latency. + (Server Server, IPAddress IpAddress, long Result)[] sortedServerAndLatencyResults = serverAndLatencyResults + .Where(server => server.IpAddress.AddressFamily is AddressFamily.InterNetworkV6 && server.Result is not long.MaxValue) + .Select(server => server) + .OrderBy(taskResult => taskResult.Result) + .Concat(serverAndLatencyResults + .Where(server => server.IpAddress.AddressFamily is AddressFamily.InterNetwork && server.Result is not long.MaxValue) + .Select(server => server) + .OrderBy(taskResult => taskResult.Result)) + .Concat(serverAndLatencyResults + .Where(server => server.IpAddress.AddressFamily is AddressFamily.InterNetworkV6 && server.Result is long.MaxValue) + .Select(server => server) + .OrderBy(taskResult => taskResult.Result)) + .Concat(serverAndLatencyResults + .Where(server => server.IpAddress.AddressFamily is AddressFamily.InterNetwork && server.Result is long.MaxValue) + .Select(server => server) + .OrderBy(taskResult => taskResult.Result)) + .ToArray(); - Task> pingTask = new Task>(() => - { - Logger.Log($"Attempting to ping {serverNames} ({serverIPAddress})."); - Server server = new Server(serverIPAddress.ToString(), serverNames, serverPorts); + // Do logging. + foreach ((Server _, IPAddress ipAddress, long serverLatencyValue) in sortedServerAndLatencyResults) + { + string serverLatencyString = serverLatencyValue <= MAXIMUM_LATENCY ? serverLatencyValue.ToString() : "DNF"; - using (Ping ping = new Ping()) - { - try - { - PingReply pingReply = ping.Send(serverIPAddress, MAXIMUM_LATENCY); + Logger.Log($"Lobby server IP: {ipAddress}, latency: {serverLatencyString}."); + } - if (pingReply.Status == IPStatus.Success) - { - long pingInMs = pingReply.RoundtripTime; - Logger.Log($"The latency in milliseconds to the server {serverNames} ({serverIPAddress}): {pingInMs}."); + int candidateCount = sortedServerAndLatencyResults.Length; + int closerCount = sortedServerAndLatencyResults.Count( + serverAndLatencyResult => serverAndLatencyResult.Result <= MAXIMUM_LATENCY); - return new Tuple(server, pingInMs); - } - else - { - Logger.Log($"Failed to ping the server {serverNames} ({serverIPAddress}): " + - $"{Enum.GetName(typeof(IPStatus), pingReply.Status)}."); + Logger.Log($"Lobby servers: {candidateCount} available, {closerCount} fast."); + connectionManager.OnServerLatencyTested(candidateCount, closerCount); - return new Tuple(server, long.MaxValue); - } - } - catch (PingException ex) - { - Logger.Log($"Caught an exception when pinging {serverNames} ({serverIPAddress}) Lobby server: {ex.Message}"); + return sortedServerAndLatencyResults.Select(taskResult => taskResult.Server).ToList(); + } - return new Tuple(server, long.MaxValue); - } - } - }); + private static async Task<(Server Server, IPAddress IpAddress, long Result)> PingServerAsync((IPAddress IpAddress, string Name, int[] Ports) serverInfo) + { + Logger.Log($"Attempting to ping {serverInfo.Name} ({serverInfo.IpAddress})."); + var server = new Server(serverInfo.IpAddress.ToString(), serverInfo.Name, serverInfo.Ports); + using var ping = new Ping(); - pingTask.Start(); - pingTasks.Add(pingTask); - } + try + { + PingReply pingReply = await ping.SendPingAsync(serverInfo.IpAddress, MAXIMUM_LATENCY).ConfigureAwait(false); + + if (pingReply.Status is IPStatus.Success) + { + long pingInMs = pingReply.RoundtripTime; + Logger.Log($"The latency in milliseconds to the server {serverInfo.Name} ({serverInfo.IpAddress}): {pingInMs}."); - Task.WaitAll(pingTasks.ToArray()); + return (server, serverInfo.IpAddress, pingInMs); + } - // Sort the servers by latency. - IOrderedEnumerable> - sortedServerAndLatencyResults = pingTasks.Select(task => task.Result) // Tuple - .OrderBy(taskResult => taskResult.Item2); // Latency + Logger.Log($"Failed to ping the server {serverInfo.Name} ({serverInfo.IpAddress}): " + + $"{Enum.GetName(typeof(IPStatus), pingReply.Status)}."); - // Do logging. - foreach (Tuple serverAndLatencyResult in sortedServerAndLatencyResults) + return (server, serverInfo.IpAddress, long.MaxValue); + } + catch (PingException ex) { - string serverIPAddress = serverAndLatencyResult.Item1.Host; - long serverLatencyValue = serverAndLatencyResult.Item2; - string serverLatencyString = serverLatencyValue <= MAXIMUM_LATENCY ? serverLatencyValue.ToString() : "DNF"; + ProgramConstants.LogException(ex, $"Caught an exception when pinging {serverInfo.Name} ({serverInfo.IpAddress}) Lobby server."); - Logger.Log($"Lobby server IP: {serverIPAddress}, latency: {serverLatencyString}."); + return (server, serverInfo.IpAddress, long.MaxValue); } + } + private static async Task> ResolveServerAsync(Server server) + { + Logger.Log($"Attempting to DNS resolve {server.Name} ({server.Host})."); + + try { - int candidateCount = sortedServerAndLatencyResults.Count(); - int closerCount = sortedServerAndLatencyResults.Count( - serverAndLatencyResult => serverAndLatencyResult.Item2 <= MAXIMUM_LATENCY); + // If hostNameOrAddress is an IP address, this address is returned without querying the DNS server. + IPAddress[] serverIPAddresses = (await Dns.GetHostAddressesAsync(server.Host).ConfigureAwait(false)) + .Where(IPAddress => IPAddress.AddressFamily is AddressFamily.InterNetworkV6 or AddressFamily.InterNetwork) + .ToArray(); + + Logger.Log($"DNS resolved {server.Name} ({server.Host}): " + + $"{string.Join(", ", serverIPAddresses.Select(item => item.ToString()))}"); - Logger.Log($"Lobby servers: {candidateCount} available, {closerCount} fast."); - connectionManager.OnServerLatencyTested(candidateCount, closerCount); + // Store each IPAddress in a different tuple. + return serverIPAddresses.Select(serverIPAddress => (serverIPAddress, server.Name, server.Ports)); + } + catch (SocketException ex) + { + ProgramConstants.LogException(ex, $"Caught an exception when DNS resolving {server.Name} ({server.Host}) Lobby server."); } - return sortedServerAndLatencyResults.Select(taskResult => taskResult.Item1).ToList(); // Server + return Array.Empty<(IPAddress IpAddress, string Name, int[] Ports)>(); } - public void Disconnect() + public async ValueTask DisconnectAsync() { - disconnect = true; - SendMessage("QUIT"); - - tcpClient.Close(); - serverStream.Close(); + await SendMessageAsync(IRCCommands.QUIT).ConfigureAwait(false); + connectionCancellationTokenSource.Cancel(); + socket.Shutdown(SocketShutdown.Both); + socket.Close(); } #region Handling commands @@ -497,7 +469,7 @@ public void Disconnect() /// message, and handles it accordingly. /// /// The message. - private void HandleMessage(string message) + private async ValueTask HandleMessageAsync(string message) { string msg = overMessage + message; overMessage = ""; @@ -512,15 +484,15 @@ private void HandleMessage(string message) } else if (msg.Length != commandEndIndex + 1) { - string command = msg.Substring(0, commandEndIndex - 1); - PerformCommand(command); + string command = msg[..(commandEndIndex - 1)]; + await PerformCommandAsync(command).ConfigureAwait(false); msg = msg.Remove(0, commandEndIndex + 1); } else { - string command = msg.Substring(0, msg.Length - 1); - PerformCommand(command); + string command = msg[..^1]; + await PerformCommandAsync(command).ConfigureAwait(false); break; } } @@ -529,22 +501,19 @@ private void HandleMessage(string message) /// /// Handles a specific command received from the IRC server. /// - private void PerformCommand(string message) + private async ValueTask PerformCommandAsync(string message) { - string prefix = String.Empty; - string command = String.Empty; - message = message.Replace("\r", String.Empty); - List parameters = new List(); - ParseIrcMessage(message, out prefix, out command, out parameters); - string paramString = String.Empty; + message = message.Replace("\r", string.Empty); + ParseIrcMessage(message, out string prefix, out string command, out List parameters); + string paramString = string.Empty; foreach (string param in parameters) { paramString = paramString + param + ","; } +#if !DEBUG Logger.Log("RMP: " + prefix + " " + command + " " + paramString); +#endif try { - bool success = false; - int commandNumber = -1; - success = Int32.TryParse(command, out commandNumber); + bool success = int.TryParse(command, out int commandNumber); if (success) { @@ -626,7 +595,7 @@ private void PerformCommand(string message) connectionManager.OnNameAlreadyInUse(); break; case 451: // Not registered - Register(); + await RegisterAsync().ConfigureAwait(false); connectionManager.OnGenericServerMessageReceived(message); break; case 471: // Returned when attempting to join a channel that is full (basically, player limit met) @@ -648,117 +617,111 @@ private void PerformCommand(string message) switch (command) { - case "NOTICE": + case IRCCommands.NOTICE: int noticeExclamIndex = prefix.IndexOf('!'); if (noticeExclamIndex > -1) { - if (parameters.Count > 1 && parameters[1][0] == 1)//Conversions.IntFromString(parameters[1].Substring(0, 1), -1) == 1) + if (parameters.Count > 1 && parameters[1][0] == 1) { // CTCP string channelName = parameters[0]; string ctcpMessage = parameters[1]; ctcpMessage = ctcpMessage.Remove(0, 1).Remove(ctcpMessage.Length - 2); - string ctcpSender = prefix.Substring(0, noticeExclamIndex); + string ctcpSender = prefix[..noticeExclamIndex]; connectionManager.OnCTCPParsed(channelName, ctcpSender, ctcpMessage); return; } - else - { - string noticeUserName = prefix.Substring(0, noticeExclamIndex); - string notice = parameters[parameters.Count - 1]; - connectionManager.OnNoticeMessageParsed(notice, noticeUserName); - break; - } + + string noticeUserName = prefix[..noticeExclamIndex]; + string notice = parameters[^1]; + connectionManager.OnNoticeMessageParsed(notice, noticeUserName); + break; } - string noticeParamString = String.Empty; + string noticeParamString = string.Empty; foreach (string param in parameters) noticeParamString = noticeParamString + param + " "; connectionManager.OnGenericServerMessageReceived(prefix + " " + noticeParamString); break; - case "JOIN": + case IRCCommands.JOIN: string channel = parameters[0]; int atIndex = prefix.IndexOf('@'); int exclamIndex = prefix.IndexOf('!'); - string userName = prefix.Substring(0, exclamIndex); + string userName = prefix[..exclamIndex]; string ident = prefix.Substring(exclamIndex + 1, atIndex - (exclamIndex + 1)); - string host = prefix.Substring(atIndex + 1); + string host = prefix[(atIndex + 1)..]; connectionManager.OnUserJoinedChannel(channel, host, userName, ident); break; - case "PART": + case IRCCommands.PART: string pChannel = parameters[0]; - string pUserName = prefix.Substring(0, prefix.IndexOf('!')); + string pUserName = prefix[..prefix.IndexOf('!')]; connectionManager.OnUserLeftChannel(pChannel, pUserName); break; - case "QUIT": - string qUserName = prefix.Substring(0, prefix.IndexOf('!')); + case IRCCommands.QUIT: + string qUserName = prefix[..prefix.IndexOf('!')]; connectionManager.OnUserQuitIRC(qUserName); break; - case "PRIVMSG": - if (parameters.Count > 1 && Convert.ToInt32(parameters[1][0]) == 1 && !parameters[1].Contains("ACTION")) + case IRCCommands.PRIVMSG: + if (parameters.Count > 1 && Convert.ToInt32(parameters[1][0]) == 1 && !parameters[1].Contains(IRCCommands.PRIVMSG_ACTION)) { - goto case "NOTICE"; + goto case IRCCommands.NOTICE; } - string pmsgUserName = prefix.Substring(0, prefix.IndexOf('!')); + string pmsgUserName = prefix[..prefix.IndexOf('!')]; string pmsgIdent = GetIdentFromPrefix(prefix); string[] recipients = new string[parameters.Count - 1]; for (int pid = 0; pid < parameters.Count - 1; pid++) recipients[pid] = parameters[pid]; - string privmsg = parameters[parameters.Count - 1]; - if (parameters[1].StartsWith('\u0001'.ToString() + "ACTION")) - privmsg = privmsg.Substring(1).Remove(privmsg.Length - 2); + string privmsg = parameters[^1]; + if (parameters[1].StartsWith('\u0001' + IRCCommands.PRIVMSG_ACTION)) + privmsg = privmsg[1..].Remove(privmsg.Length - 2); foreach (string recipient in recipients) { if (recipient.StartsWith("#")) connectionManager.OnChatMessageReceived(recipient, pmsgUserName, pmsgIdent, privmsg); else if (recipient == ProgramConstants.PLAYERNAME) connectionManager.OnPrivateMessageReceived(pmsgUserName, privmsg); - //else if (pmsgUserName == ProgramConstants.PLAYERNAME) - //{ - // DoPrivateMessageSent(privmsg, recipient); - //} } break; - case "MODE": - string modeUserName = prefix.Substring(0, prefix.IndexOf('!')); + case IRCCommands.MODE: + string modeUserName = prefix.Contains('!') ? prefix[..prefix.IndexOf('!')] : prefix; string modeChannelName = parameters[0]; string modeString = parameters[1]; List modeParameters = parameters.Count > 2 ? parameters.GetRange(2, parameters.Count - 2) : new List(); connectionManager.OnChannelModesChanged(modeUserName, modeChannelName, modeString, modeParameters); break; - case "KICK": + case IRCCommands.KICK: string kickChannelName = parameters[0]; string kickUserName = parameters[1]; connectionManager.OnUserKicked(kickChannelName, kickUserName); break; - case "ERROR": + case IRCCommands.ERROR: connectionManager.OnErrorReceived(message); break; - case "PING": + case IRCCommands.PING: if (parameters.Count > 0) { - QueueMessage(new QueuedMessage("PONG " + parameters[0], QueuedMessageType.SYSTEM_MESSAGE, 5000)); - Logger.Log("PONG " + parameters[0]); + await QueueMessageAsync(new QueuedMessage(IRCCommands.PONG + " " + parameters[0], QueuedMessageType.SYSTEM_MESSAGE, 5000)).ConfigureAwait(false); + Logger.Log(IRCCommands.PONG + " " + parameters[0]); } else { - QueueMessage(new QueuedMessage("PONG", QueuedMessageType.SYSTEM_MESSAGE, 5000)); - Logger.Log("PONG"); + await QueueMessageAsync(new QueuedMessage(IRCCommands.PONG, QueuedMessageType.SYSTEM_MESSAGE, 5000)).ConfigureAwait(false); + Logger.Log(IRCCommands.PONG); } break; - case "TOPIC": + case IRCCommands.TOPIC: if (parameters.Count < 2) break; - connectionManager.OnChannelTopicChanged(prefix.Substring(0, prefix.IndexOf('!')), + connectionManager.OnChannelTopicChanged(prefix[..prefix.IndexOf('!')], parameters[0], parameters[1]); break; - case "NICK": + case IRCCommands.NICK: int nickExclamIndex = prefix.IndexOf('!'); if (nickExclamIndex > -1 || parameters.Count < 1) { - string oldNick = prefix.Substring(0, nickExclamIndex); + string oldNick = prefix[..nickExclamIndex]; string newNick = parameters[0]; Logger.Log("Nick change - " + oldNick + " -> " + newNick); connectionManager.OnUserNicknameChange(oldNick, newNick); @@ -766,9 +729,9 @@ private void PerformCommand(string message) break; } } - catch + catch (Exception ex) { - Logger.Log("Warning: Failed to parse command " + message); + ProgramConstants.LogException(ex, "Warning: Failed to parse command " + message); } } @@ -793,7 +756,7 @@ private string GetIdentFromPrefix(string prefix) private void ParseIrcMessage(string message, out string prefix, out string command, out List parameters) { int prefixEnd = -1; - prefix = command = String.Empty; + prefix = command = string.Empty; parameters = new List(); // Grab the prefix if it is present. If a message begins @@ -811,7 +774,7 @@ private void ParseIrcMessage(string message, out string prefix, out string comma int trailingStart = message.IndexOf(" :"); string trailing = null; if (trailingStart >= 0) - trailing = message.Substring(trailingStart + 2); + trailing = message[(trailingStart + 2)..]; else trailingStart = message.Length; @@ -821,7 +784,7 @@ private void ParseIrcMessage(string message, out string prefix, out string comma if (commandAndParameters.Length == 0) { - command = String.Empty; + command = string.Empty; Logger.Log("Nonexistant command!"); return; } @@ -849,101 +812,114 @@ private void ParseIrcMessage(string message, out string prefix, out string comma #region Sending commands - private void RunSendQueue() + private async ValueTask RunSendQueueAsync(CancellationToken cancellationToken) { - while (_isConnected) + try { - string message = String.Empty; - - lock (messageQueueLocker) + while (!cancellationToken.IsCancellationRequested) { - for (int i = 0; i < MessageQueue.Count; i++) + string message = string.Empty; + + await messageQueueLocker.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { - QueuedMessage qm = MessageQueue[i]; - if (qm.Delay > 0) + for (int i = 0; i < messageQueue.Count; i++) { - if (qm.SendAt < DateTime.Now) + QueuedMessage qm = messageQueue[i]; + if (qm.Delay > 0) { - message = qm.Command; + if (qm.SendAt < DateTime.Now) + { + message = qm.Command; - Logger.Log("Delayed message sent: " + qm.ID); + Logger.Log("Delayed message sent: " + qm.ID); - MessageQueue.RemoveAt(i); + messageQueue.RemoveAt(i); + break; + } + } + else + { + message = qm.Command; + messageQueue.RemoveAt(i); break; } } - else - { - message = qm.Command; - MessageQueue.RemoveAt(i); - break; - } } - } - - if (String.IsNullOrEmpty(message)) - { - Thread.Sleep(10); - continue; - } + finally + { + messageQueueLocker.Release(); + } - SendMessage(message); + if (string.IsNullOrEmpty(message)) + { + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + continue; + } - Thread.Sleep(MessageQueueDelay); + await SendMessageAsync(message).ConfigureAwait(false); + await Task.Delay(messageQueueDelay, cancellationToken).ConfigureAwait(false); + } } - - lock (messageQueueLocker) + catch (OperationCanceledException) { - MessageQueue.Clear(); } + finally + { + await messageQueueLocker.WaitAsync(CancellationToken.None).ConfigureAwait(false); + + try + { + messageQueue.Clear(); + } + finally + { + messageQueueLocker.Release(); + } - sendQueueExited = true; + sendQueueExited = true; + } } /// /// Sends a PING message to the server to indicate that we're still connected. /// - /// Just a dummy parameter so that this matches the delegate System.Threading.TimerCallback. - private void AutoPing(object data) - { - SendMessage("PING LAG" + new Random().Next(100000, 999999)); - } + private ValueTask AutoPingAsync() + => SendMessageAsync(IRCCommands.PING_LAG + new Random().Next(100000, 999999)); /// /// Registers the user. /// - private void Register() + private async ValueTask RegisterAsync() { if (welcomeMessageReceived) return; Logger.Log("Registering."); - var defaultGame = ClientConfiguration.Instance.LocalGame; - - string realname = ProgramConstants.GAME_VERSION + " " + defaultGame + " CnCNet"; - - SendMessage(string.Format("USER {0} 0 * :{1}", defaultGame + "." + - systemId, realname)); + string defaultGame = ClientConfiguration.Instance.LocalGame; + string realName = ProgramConstants.GAME_VERSION + " " + defaultGame + " CnCNet"; - SendMessage("NICK " + ProgramConstants.PLAYERNAME); + await SendMessageAsync(FormattableString.Invariant($"{IRCCommands.USER} {defaultGame}.{systemId} 0 * :{realName}")).ConfigureAwait(false); + await SendMessageAsync(IRCCommands.NICK + " " + ProgramConstants.PLAYERNAME).ConfigureAwait(false); } - public void ChangeNickname() + public ValueTask ChangeNicknameAsync() { - SendMessage("NICK " + ProgramConstants.PLAYERNAME); + return SendMessageAsync(IRCCommands.NICK + " " + ProgramConstants.PLAYERNAME); } - public void QueueMessage(QueuedMessageType type, int priority, string message, bool replace = false) + public ValueTask QueueMessageAsync(QueuedMessageType type, int priority, string message, bool replace = false) { QueuedMessage qm = new QueuedMessage(message, type, priority, replace); - QueueMessage(qm); + return QueueMessageAsync(qm); } - public void QueueMessage(QueuedMessageType type, int priority, int delay, string message) + public async ValueTask QueueMessageAsync(QueuedMessageType type, int priority, int delay, string message) { QueuedMessage qm = new QueuedMessage(message, type, priority, delay); - QueueMessage(qm); + await QueueMessageAsync(qm).ConfigureAwait(false); Logger.Log("Setting delay to " + delay + "ms for " + qm.ID); } @@ -951,29 +927,38 @@ public void QueueMessage(QueuedMessageType type, int priority, int delay, string /// Send a message to the CnCNet server. /// /// The message to send. - private void SendMessage(string message) + private async ValueTask SendMessageAsync(string message) { - if (serverStream == null) + if (!socket?.Connected ?? false) return; Logger.Log("SRM: " + message); - byte[] buffer = encoding.GetBytes(message + "\r\n"); - if (serverStream.CanWrite) + const int charSize = sizeof(char); + int bufferSize = message.Length * charSize; + using IMemoryOwner memoryOwner = MemoryPool.Shared.Rent(bufferSize); + Memory buffer = memoryOwner.Memory[..bufferSize]; + int bytes = Encoding.UTF8.GetBytes((message + "\r\n").AsSpan(), buffer.Span); + + buffer = buffer[..bytes]; + + using var timeoutCancellationTokenSource = new CancellationTokenSource(SendTimeout); + + try { - try - { - serverStream.Write(buffer, 0, buffer.Length); - serverStream.Flush(); - } - catch (IOException ex) - { - Logger.Log("Sending message to the server failed! Reason: " + ex.Message); - } + await socket.SendAsync(buffer, SocketFlags.None, timeoutCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (SocketException ex) + { + ProgramConstants.LogException(ex, "Sending message to the server failed!"); + } + catch (OperationCanceledException ex) + { + ProgramConstants.LogException(ex, "Sending message to the server timed out!"); } } - private int NextQueueID { get; set; } = 0; + private int NextQueueID { get; set; } /// /// This will attempt to replace a previously queued message of the same type. @@ -982,25 +967,30 @@ private void SendMessage(string message) /// Whether or not a replace occurred private bool ReplaceMessage(QueuedMessage qm) { - lock (messageQueueLocker) + messageQueueLocker.Wait(); + + try { - var previousMessageIndex = MessageQueue.FindIndex(m => m.MessageType == qm.MessageType); + var previousMessageIndex = messageQueue.FindIndex(m => m.MessageType == qm.MessageType); if (previousMessageIndex == -1) return false; - MessageQueue[previousMessageIndex] = qm; + messageQueue[previousMessageIndex] = qm; return true; } + finally + { + messageQueueLocker.Release(); + } } /// /// Adds a message to the send queue. /// /// The message to queue. - /// If true, attempt to replace a previous message of the same type - public void QueueMessage(QueuedMessage qm) + public async ValueTask QueueMessageAsync(QueuedMessage qm) { - if (!_isConnected) + if (!IsConnected) return; if (qm.Replace && ReplaceMessage(qm)) @@ -1008,7 +998,9 @@ public void QueueMessage(QueuedMessage qm) qm.ID = NextQueueID++; - lock (messageQueueLocker) + await messageQueueLocker.WaitAsync().ConfigureAwait(false); + + try { switch (qm.MessageType) { @@ -1025,19 +1017,23 @@ public void QueueMessage(QueuedMessage qm) AddSpecialQueuedMessage(qm); break; case QueuedMessageType.INSTANT_MESSAGE: - SendMessage(qm.Command); + await SendMessageAsync(qm.Command).ConfigureAwait(false); break; default: - int placeInQueue = MessageQueue.FindIndex(m => m.Priority < qm.Priority); + int placeInQueue = messageQueue.FindIndex(m => m.Priority < qm.Priority); if (ProgramConstants.LOG_LEVEL > 1) Logger.Log("QM Undefined: " + qm.Command + " " + placeInQueue); if (placeInQueue == -1) - MessageQueue.Add(qm); + messageQueue.Add(qm); else - MessageQueue.Insert(placeInQueue, qm); + messageQueue.Insert(placeInQueue, qm); break; } } + finally + { + messageQueueLocker.Release(); + } } /// @@ -1047,7 +1043,7 @@ public void QueueMessage(QueuedMessage qm) /// The message to queue. private void AddSpecialQueuedMessage(QueuedMessage qm) { - int broadcastingMessageIndex = MessageQueue.FindIndex(m => m.MessageType == qm.MessageType); + int broadcastingMessageIndex = messageQueue.FindIndex(m => m.MessageType == qm.MessageType); qm.ID = NextQueueID++; @@ -1055,17 +1051,17 @@ private void AddSpecialQueuedMessage(QueuedMessage qm) { if (ProgramConstants.LOG_LEVEL > 1) Logger.Log("QM Replace: " + qm.Command + " " + broadcastingMessageIndex); - MessageQueue[broadcastingMessageIndex] = qm; + messageQueue[broadcastingMessageIndex] = qm; } else { - int placeInQueue = MessageQueue.FindIndex(m => m.Priority < qm.Priority); + int placeInQueue = messageQueue.FindIndex(m => m.Priority < qm.Priority); if (ProgramConstants.LOG_LEVEL > 1) Logger.Log("QM: " + qm.Command + " " + placeInQueue); if (placeInQueue == -1) - MessageQueue.Add(qm); + messageQueue.Add(qm); else - MessageQueue.Insert(placeInQueue, qm); + messageQueue.Insert(placeInQueue, qm); } } diff --git a/DXMainClient/Online/IConnectionManager.cs b/DXMainClient/Online/IConnectionManager.cs index f283245ab..538348195 100644 --- a/DXMainClient/Online/IConnectionManager.cs +++ b/DXMainClient/Online/IConnectionManager.cs @@ -71,22 +71,6 @@ public interface IConnectionManager void OnConnected(); - bool GetDisconnectStatus(); - void OnServerLatencyTested(int candidateCount, int closerCount); - - //public EventHandler WelcomeMessageReceived; - //public EventHandler GenericServerMessageReceived; - //public EventHandler AwayMessageReceived; - //public EventHandler ChannelTopicReceived; - //public EventHandler UserListReceived; - //public EventHandler WhoReplyReceived; - //public EventHandler ChannelFull; - //public EventHandler IncorrectChannelPassword; - - //public event EventHandler AttemptedServerChanged; - //public event EventHandler ConnectAttemptFailed; - //public event EventHandler ConnectionLost; - //public event EventHandler ReconnectAttempt; } -} +} \ No newline at end of file diff --git a/DXMainClient/Online/PrivateMessageHandler.cs b/DXMainClient/Online/PrivateMessageHandler.cs index 4e03f1875..aa1e5b262 100644 --- a/DXMainClient/Online/PrivateMessageHandler.cs +++ b/DXMainClient/Online/PrivateMessageHandler.cs @@ -8,7 +8,7 @@ namespace DTAClient.Online /// as to whether the message should be ignored, independent from any GUI. This will then forward valid private message /// events to other consumers. /// - public class PrivateMessageHandler + internal sealed class PrivateMessageHandler { private readonly CnCNetUserData _cncnetUserData; private readonly CnCNetManager _connectionManager; diff --git a/DXMainClient/Online/QueuedMessage.cs b/DXMainClient/Online/QueuedMessage.cs index b980b5071..c87e030e8 100644 --- a/DXMainClient/Online/QueuedMessage.cs +++ b/DXMainClient/Online/QueuedMessage.cs @@ -9,13 +9,13 @@ public class QueuedMessage { private const int DEFAULT_DELAY = -1; private const int REPLACE_DELAY = 1; - - public QueuedMessage(string command, QueuedMessageType type, int priority) : + + public QueuedMessage(string command, QueuedMessageType type, int priority) : this(command, type, priority, DEFAULT_DELAY, false) { } - public QueuedMessage(string command, QueuedMessageType type, int priority, bool replace) : + public QueuedMessage(string command, QueuedMessageType type, int priority, bool replace) : this(command, type, priority, replace ? REPLACE_DELAY : DEFAULT_DELAY, replace) { } @@ -31,7 +31,7 @@ private QueuedMessage(string command, QueuedMessageType type, int priority, int MessageType = type; Priority = priority; Delay = delay; - SendAt = Delay < 0 ? DateTime.Now : DateTime.Now.AddMilliseconds(Delay); + SendAt = Delay < 0 ? DateTime.Now : DateTime.Now.AddMilliseconds(Delay); Replace = replace; } diff --git a/DXMainClient/PreStartup.cs b/DXMainClient/PreStartup.cs index 2e25c91bb..e71f5a64d 100644 --- a/DXMainClient/PreStartup.cs +++ b/DXMainClient/PreStartup.cs @@ -56,9 +56,9 @@ public static void Initialize(StartupParams parameters) #if WINFORMS Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); - Application.ThreadException += (sender, args) => HandleException(sender, args.Exception); + Application.ThreadException += (_, args) => ProgramConstants.HandleException(args.Exception); #endif - AppDomain.CurrentDomain.UnhandledException += (sender, args) => HandleException(sender, (Exception)args.ExceptionObject); + AppDomain.CurrentDomain.UnhandledException += (_, args) => ProgramConstants.HandleException((Exception)args.ExceptionObject); DirectoryInfo gameDirectory = SafePath.GetDirectory(ProgramConstants.GamePath); @@ -80,9 +80,24 @@ public static void Initialize(StartupParams parameters) if (!clientUserFilesDirectory.Exists) clientUserFilesDirectory.Create(); - MainClientConstants.Initialize(); + ProgramConstants.OSId = ClientConfiguration.Instance.GetOperatingSystemVersion(); + ProgramConstants.GAME_NAME_SHORT = ClientConfiguration.Instance.LocalGame; + ProgramConstants.GAME_NAME_LONG = ClientConfiguration.Instance.LongGameName; + ProgramConstants.SUPPORT_URL_SHORT = ClientConfiguration.Instance.ShortSupportURL; + ProgramConstants.CREDITS_URL = ClientConfiguration.Instance.CreditsURL; + ProgramConstants.MAP_CELL_SIZE_X = ClientConfiguration.Instance.MapCellSizeX; + ProgramConstants.MAP_CELL_SIZE_Y = ClientConfiguration.Instance.MapCellSizeY; - Logger.Log("***Logfile for " + MainClientConstants.GAME_NAME_LONG + " client***"); + if (string.IsNullOrEmpty(ProgramConstants.GAME_NAME_SHORT)) + throw new ClientConfigurationException("LocalGame is set to an empty value."); + + if (ProgramConstants.GAME_NAME_SHORT.Length > ProgramConstants.GAME_ID_MAX_LENGTH) + { + throw new ClientConfigurationException("LocalGame is set to a value that exceeds length limit of " + + ProgramConstants.GAME_ID_MAX_LENGTH + " characters."); + } + + Logger.Log("***Logfile for " + ProgramConstants.GAME_NAME_LONG + " client***"); Logger.Log("Client version: " + Assembly.GetAssembly(typeof(PreStartup)).GetName().Version); // Log information about given startup params @@ -132,7 +147,7 @@ public static void Initialize(StartupParams parameters) } catch (Exception ex) { - Logger.Log("Failed to load the translation file. " + ex.Message); + ProgramConstants.LogException(ex, "Failed to load the translation file."); Translation.Instance = new Translation(UserINISettings.Instance.Translation); } @@ -163,13 +178,13 @@ public static void Initialize(StartupParams parameters) } catch (Exception ex) { - Logger.Log("Failed to generate the translation stub: " + ex.Message); + ProgramConstants.LogException(ex, "Failed to generate the translation stub."); } // Delete obsolete files from old target project versions gameDirectory.EnumerateFiles("mainclient.log").SingleOrDefault()?.Delete(); - gameDirectory.EnumerateFiles("aunchupdt.dat").SingleOrDefault()?.Delete(); + gameDirectory.EnumerateFiles("launchupdt.dat").SingleOrDefault()?.Delete(); try { @@ -177,7 +192,7 @@ public static void Initialize(StartupParams parameters) } catch (Exception ex) { - LogException(ex); + ProgramConstants.LogException(ex); string error = "Deleting wsock32.dll failed! Please close any " + "applications that could be using the file, and then start the client again." @@ -194,65 +209,16 @@ public static void Initialize(StartupParams parameters) new Startup().Execute(); } - public static void LogException(Exception ex, bool innerException = false) - { - if (!innerException) - Logger.Log("KABOOOOOOM!!! Info:"); - else - Logger.Log("InnerException info:"); - - Logger.Log("Type: " + ex.GetType()); - Logger.Log("Message: " + ex.Message); - Logger.Log("Source: " + ex.Source); - Logger.Log("TargetSite.Name: " + ex.TargetSite.Name); - Logger.Log("Stacktrace: " + ex.StackTrace); - - if (ex.InnerException is not null) - LogException(ex.InnerException, true); - } - - static void HandleException(object sender, Exception ex) - { - LogException(ex); - - string errorLogPath = SafePath.CombineFilePath(ProgramConstants.ClientUserFilesPath, "ClientCrashLogs", FormattableString.Invariant($"ClientCrashLog{DateTime.Now.ToString("_yyyy_MM_dd_HH_mm")}.txt")); - bool crashLogCopied = false; - - try - { - DirectoryInfo crashLogsDirectoryInfo = SafePath.GetDirectory(ProgramConstants.ClientUserFilesPath, "ClientCrashLogs"); - - if (!crashLogsDirectoryInfo.Exists) - crashLogsDirectoryInfo.Create(); - - File.Copy(SafePath.CombineFilePath(ProgramConstants.ClientUserFilesPath, "client.log"), errorLogPath, true); - crashLogCopied = true; - } - catch { } - - string error = string.Format("{0} has crashed. Error message:".L10N("Client:Main:FatalErrorText1") + Environment.NewLine + Environment.NewLine + - ex.Message + Environment.NewLine + Environment.NewLine + (crashLogCopied ? - "A crash log has been saved to the following file:".L10N("Client:Main:FatalErrorText2") + " " + Environment.NewLine + Environment.NewLine + - errorLogPath + Environment.NewLine + Environment.NewLine : "") + - (crashLogCopied ? "If the issue is repeatable, contact the {1} staff at {2} and provide the crash log file.".L10N("Client:Main:FatalErrorText3") : - "If the issue is repeatable, contact the {1} staff at {2}.".L10N("Client:Main:FatalErrorText4")), - MainClientConstants.GAME_NAME_LONG, - MainClientConstants.GAME_NAME_SHORT, - MainClientConstants.SUPPORT_URL_SHORT); - - ProgramConstants.DisplayErrorAction("KABOOOOOOOM".L10N("Client:Main:FatalErrorTitle"), error, true); - } - [SupportedOSPlatform("windows")] private static void CheckPermissions() { if (UserHasDirectoryAccessRights(ProgramConstants.GamePath, FileSystemRights.Modify)) return; - string error = string.Format(("You seem to be running {0} from a write-protected directory.\n\n" + + string error = string.Format(("You seem to be running {0} from a write-protected directory.\n\n" + "For {1} to function properly when run from a write-protected directory, it needs administrative priveleges.\n\n" + "Would you like to restart the client with administrative rights?\n\n" + - "Please also make sure that your security software isn't blocking {1}.").L10N("Client:Main:AdminRequiredText"), MainClientConstants.GAME_NAME_LONG, MainClientConstants.GAME_NAME_SHORT); + "Please also make sure that your security software isn't blocking {1}.").L10N("Client:Main:AdminRequiredText"), ProgramConstants.GAME_NAME_LONG, ProgramConstants.GAME_NAME_SHORT); ProgramConstants.DisplayErrorAction("Administrative privileges required".L10N("Client:Main:AdminRequiredTitle"), error, false); @@ -320,6 +286,7 @@ private static bool UserHasDirectoryAccessRights(string path, FileSystemRights a { return false; } + return isInRoleWithAccess; } } diff --git a/DXMainClient/Program.cs b/DXMainClient/Program.cs index b8bfe3079..6a7fd6f1b 100644 --- a/DXMainClient/Program.cs +++ b/DXMainClient/Program.cs @@ -10,7 +10,7 @@ namespace DTAClient { - static class Program + internal static class Program { #if !DEBUG static Program() @@ -50,7 +50,7 @@ Yuri has won #if WINFORMS [STAThread] #endif - static void Main(string[] args) + private static void Main(string[] args) { bool noAudio = false; bool multipleInstanceMode = false; @@ -74,18 +74,6 @@ static void Main(string[] args) } } - var parameters = new StartupParams(noAudio, multipleInstanceMode, unknownStartupParams); - - if (multipleInstanceMode) - { - // Proceed to client startup - PreStartup.Initialize(parameters); - return; - } - - // We're a single instance application! - // http://stackoverflow.com/questions/229565/what-is-a-good-pattern-for-using-a-global-mutex-in-c/229567 - // Global prefix means that the mutex is global to the machine string mutexId = FormattableString.Invariant($"Global{Guid.Parse("1CC9F8E7-9F69-4BBC-B045-E734204027A9")}"); using var mutex = new Mutex(false, mutexId, out _); bool hasHandle = false; @@ -95,19 +83,17 @@ static void Main(string[] args) try { hasHandle = mutex.WaitOne(8000, false); - if (hasHandle == false) - throw new TimeoutException("Timeout waiting for exclusive access"); + + if (hasHandle is false && !multipleInstanceMode) + return; } catch (AbandonedMutexException) { hasHandle = true; } - catch (TimeoutException) - { - return; - } - // Proceed to client startup + var parameters = new StartupParams(noAudio, multipleInstanceMode, unknownStartupParams); + PreStartup.Initialize(parameters); } finally diff --git a/DXMainClient/Properties/launchSettings.json b/DXMainClient/Properties/launchSettings.json index 030ba9b5b..770993e99 100644 --- a/DXMainClient/Properties/launchSettings.json +++ b/DXMainClient/Properties/launchSettings.json @@ -1,10 +1,12 @@ { "profiles": { "DXMainClient": { - "commandName": "Project" + "commandName": "Project", + "commandLineArgs": "-MULTIPLEINSTANCE" }, "WSL": { "commandName": "WSL2", + "commandLineArgs": "-MULTIPLEINSTANCE", "distributionName": "" } } diff --git a/DXMainClient/Resources/run.sh b/DXMainClient/Resources/run.sh deleted file mode 100755 index ec5aa9316..000000000 --- a/DXMainClient/Resources/run.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -env MONO_IOMAP=all mono --debug DTAClient.exe \ No newline at end of file diff --git a/DXMainClient/Resources/wine-dta.sh b/DXMainClient/Resources/wine-dta.sh new file mode 100644 index 000000000..c40c6b551 --- /dev/null +++ b/DXMainClient/Resources/wine-dta.sh @@ -0,0 +1,2 @@ +#!/bin/sh +wine gamemd-spawn.exe $* \ No newline at end of file diff --git a/DXMainClient/Startup.cs b/DXMainClient/Startup.cs index c268ec00a..b6ecce858 100644 --- a/DXMainClient/Startup.cs +++ b/DXMainClient/Startup.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Threading; using Microsoft.Win32; using DTAClient.Domain; using ClientCore; @@ -17,6 +16,7 @@ using System.Management; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using ClientCore.Extensions; using ClientCore.Settings; using Microsoft.Xna.Framework.Graphics; @@ -25,7 +25,7 @@ namespace DTAClient /// /// A class that handles initialization of the Client. /// - public class Startup + internal sealed class Startup { /// /// The main method for startup and initialization. @@ -40,33 +40,29 @@ public void Execute() throw new DirectoryNotFoundException("Theme directory not found!" + Environment.NewLine + ProgramConstants.RESOURCES_DIR); Logger.Log("Initializing updater."); - SafePath.DeleteFileIfExists(ProgramConstants.GamePath, "version_u"); - Updater.Initialize(ProgramConstants.GamePath, ProgramConstants.GetBaseResourcePath(), ClientConfiguration.Instance.SettingsIniName, ClientConfiguration.Instance.LocalGame, SafePath.GetFile(ProgramConstants.StartupExecutable).Name); - Logger.Log("OSDescription: " + RuntimeInformation.OSDescription); Logger.Log("OSArchitecture: " + RuntimeInformation.OSArchitecture); Logger.Log("ProcessArchitecture: " + RuntimeInformation.ProcessArchitecture); Logger.Log("FrameworkDescription: " + RuntimeInformation.FrameworkDescription); Logger.Log("RuntimeIdentifier: " + RuntimeInformation.RuntimeIdentifier); - Logger.Log("Selected OS profile: " + MainClientConstants.OSId); + Logger.Log("Selected OS profile: " + ProgramConstants.OSId); Logger.Log("Current culture: " + CultureInfo.CurrentCulture); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // The query in CheckSystemSpecifications takes lots of time, // so we'll do it in a separate thread to make startup faster - Thread thread = new Thread(CheckSystemSpecifications); - thread.Start(); + Task.Run(CheckSystemSpecifications).HandleTask(); } - GenerateOnlineIdAsync(); + GenerateOnlineIdAsync().HandleTask(); #if ARES - Task.Factory.StartNew(() => PruneFiles(SafePath.GetDirectory(ProgramConstants.GamePath, "debug"), DateTime.Now.AddDays(-7))); + Task.Run(() => PruneFiles(SafePath.GetDirectory(ProgramConstants.GamePath, "debug"), DateTime.Now.AddDays(-7))).HandleTask(); #endif - Task.Factory.StartNew(MigrateOldLogFiles); + Task.Run(MigrateOldLogFiles).HandleTask(); DirectoryInfo updaterFolder = SafePath.GetDirectory(ProgramConstants.GamePath, "Updater"); @@ -77,14 +73,15 @@ public void Execute() { updaterFolder.Delete(true); } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); } } if (ClientConfiguration.Instance.CreateSavedGamesDirectory) { - DirectoryInfo savedGamesFolder = SafePath.GetDirectory(ProgramConstants.GamePath, "Saved Games"); + DirectoryInfo savedGamesFolder = SafePath.GetDirectory(ProgramConstants.GamePath, ProgramConstants.SAVED_GAMES_DIRECTORY); if (!savedGamesFolder.Exists) { @@ -93,8 +90,9 @@ public void Execute() { savedGamesFolder.Create(); } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); } } } @@ -108,9 +106,9 @@ public void Execute() { SafePath.DeleteFileIfExists(ProgramConstants.GamePath, FormattableString.Invariant($"{component.LocalPath}_u")); } - catch + catch (Exception ex) { - + ProgramConstants.LogException(ex); } } } @@ -162,11 +160,9 @@ private void PruneFiles(DirectoryInfo directory, DateTime pruneThresholdTime) if (fileInfo.CreationTime <= pruneThresholdTime) fileInfo.Delete(); } - catch (Exception e) + catch (Exception ex) { - Logger.Log("PruneFiles: Could not delete file " + fsEntry.Name + - ". Error message: " + e.Message); - continue; + ProgramConstants.LogException(ex, "PruneFiles: Could not delete file " + fsEntry.Name + "."); } } } @@ -176,8 +172,7 @@ private void PruneFiles(DirectoryInfo directory, DateTime pruneThresholdTime) } catch (Exception ex) { - Logger.Log("PruneFiles: An error occurred while pruning files from " + - directory.Name + ". Message: " + ex.Message); + ProgramConstants.LogException(ex, "PruneFiles: An error occurred while pruning files from " + directory.Name + "."); } } #endif @@ -231,9 +226,9 @@ private static void MigrateLogFiles(DirectoryInfo newDirectory, string searchPat } catch (Exception ex) { - Logger.Log("MigrateLogFiles: An error occured while moving log files from " + + ProgramConstants.LogException(ex, "MigrateLogFiles: An error occurred while moving log files from " + currentDirectory.Name + " to " + - newDirectory.Name + ". Message: " + ex.Message); + newDirectory.Name + "."); } } @@ -247,28 +242,27 @@ private static void CheckSystemSpecifications() string videoController = string.Empty; string memory = string.Empty; - ManagementObjectSearcher searcher; - try { - searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); + using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); foreach (var proc in searcher.Get()) { cpu = cpu + proc["Name"].ToString().Trim() + " (" + proc["NumberOfCores"] + " cores) "; } - } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); + cpu = "CPU info not found"; } try { - searcher = new ManagementObjectSearcher("SELECT * FROM Win32_VideoController"); + using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_VideoController"); - foreach (ManagementObject mo in searcher.Get()) + foreach (ManagementObject mo in searcher.Get().Cast()) { var currentBitsPerPixel = mo.Properties["CurrentBitsPerPixel"]; var description = mo.Properties["Description"]; @@ -279,17 +273,19 @@ private static void CheckSystemSpecifications() } } } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); + cpu = "Video controller info not found"; } try { - searcher = new ManagementObjectSearcher("Select * From Win32_PhysicalMemory"); + using var searcher = new ManagementObjectSearcher("Select * From Win32_PhysicalMemory"); ulong total = 0; - foreach (ManagementObject ram in searcher.Get()) + foreach (ManagementObject ram in searcher.Get().Cast()) { total += Convert.ToUInt64(ram.GetPropertyValue("Capacity")); } @@ -297,8 +293,10 @@ private static void CheckSystemSpecifications() if (total != 0) memory = "Total physical memory: " + (total >= 1073741824 ? total / 1073741824 + "GB" : total / 1048576 + "MB"); } - catch + catch (Exception ex) { + ProgramConstants.LogException(ex); + cpu = "Memory info not found"; } @@ -308,73 +306,72 @@ private static void CheckSystemSpecifications() /// /// Generate an ID for online play. /// - private static async Task GenerateOnlineIdAsync() + private static async ValueTask GenerateOnlineIdAsync() { -#if !WINFORMS if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { -#endif -#pragma warning disable format try { - await Task.CompletedTask; ManagementObjectCollection mbsList = null; - ManagementObjectSearcher mbs = new ManagementObjectSearcher("Select * From Win32_processor"); + using var mbs = new ManagementObjectSearcher("Select * From Win32_processor"); mbsList = mbs.Get(); string cpuid = ""; - foreach (ManagementObject mo in mbsList) + foreach (ManagementObject mo in mbsList.Cast()) cpuid = mo["ProcessorID"].ToString(); - ManagementObjectSearcher mos = new ManagementObjectSearcher("SELECT * FROM Win32_BaseBoard"); + using var mos = new ManagementObjectSearcher("SELECT * FROM Win32_BaseBoard"); var moc = mos.Get(); string mbid = ""; - foreach (ManagementObject mo in moc) + foreach (ManagementObject mo in moc.Cast()) mbid = (string)mo["SerialNumber"]; - string sid = new SecurityIdentifier((byte[])new DirectoryEntry(string.Format("WinNT://{0},Computer", Environment.MachineName)).Children.Cast().First().InvokeGet("objectSID"), 0).AccountDomainSid.Value; + using var computer = new DirectoryEntry(FormattableString.Invariant($"WinNT://{Environment.MachineName},Computer")); + string sid = new SecurityIdentifier((byte[])computer.Children.Cast().First().InvokeGet("objectSID"), 0).AccountDomainSid.Value; Connection.SetId(cpuid + mbid + sid); using RegistryKey key = Registry.CurrentUser.CreateSubKey("SOFTWARE\\" + ClientConfiguration.Instance.InstallationPathRegKey); key.SetValue("Ident", cpuid + mbid + sid); } - catch (Exception) + catch (Exception ex) { - Random rn = new Random(); - + ProgramConstants.LogException(ex); + var rn = new Random(); using RegistryKey key = Registry.CurrentUser.CreateSubKey("SOFTWARE\\" + ClientConfiguration.Instance.InstallationPathRegKey); - string str = rn.Next(Int32.MaxValue - 1).ToString(); + string str = rn.Next(int.MaxValue - 1).ToString(CultureInfo.InvariantCulture); try { - Object o = key.GetValue("Ident"); + object o = key.GetValue("Ident"); + if (o == null) key.SetValue("Ident", str); else str = o.ToString(); } - catch { } + catch (Exception ex1) + { + ProgramConstants.LogException(ex1); + } Connection.SetId(str); } -#pragma warning restore format -#if !WINFORMS } else { try { - string machineId = await File.ReadAllTextAsync("/var/lib/dbus/machine-id"); + string machineId = await File.ReadAllTextAsync("/var/lib/dbus/machine-id").ConfigureAwait(false); Connection.SetId(machineId); } - catch (Exception) + catch (Exception ex) { - Connection.SetId(new Random().Next(int.MaxValue - 1).ToString()); + ProgramConstants.LogException(ex); + Connection.SetId(new Random().Next(int.MaxValue - 1).ToString(CultureInfo.InvariantCulture)); } } -#endif } /// @@ -396,9 +393,9 @@ private static void WriteInstallPathToRegistry() using RegistryKey key = Registry.CurrentUser.CreateSubKey("SOFTWARE\\" + ClientConfiguration.Instance.InstallationPathRegKey); key.SetValue("InstallPath", ProgramConstants.GamePath); } - catch + catch (Exception ex) { - Logger.Log("Failed to write installation path to the Windows registry"); + ProgramConstants.LogException(ex, "Failed to write installation path to the Windows registry"); } } } diff --git a/Directory.Build.props b/Directory.Build.props index a5996d3a7..6fd7b88bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -28,7 +28,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Docs/Migration.md b/Docs/Migration.md index 4734fed6c..ccd993923 100644 --- a/Docs/Migration.md +++ b/Docs/Migration.md @@ -13,7 +13,11 @@ Migrating from older versions - Second-stage updater is now maintained as a separate project. Download the latest release [here](https://github.com/CnCNet/cncnet-client-updater/releases) (select `SecondStageUpdater-.zip`), and extract its contents to the client's `Resources\Updater` folder. -- To support launching the game on Linux the file defined as `UnixGameExecutableName` (defaults to `wine-dta.sh`) in `ClientDefinitions.ini` must be set up correctly. E.g. for launching a game with wine the file could contain `wine gamemd-spawn.exe $*` where `gamemd-spawn.exe` is replaced with the game executable. Note that users might need to execute `chmod u+x wine-dta.sh` once to allow it to be launched. +- To support launching the game on Linux the file defined as `UnixGameExecutableName` (defaults to `wine-dta.sh`) in `ClientDefinitions.ini` must be set up correctly. E.g. for launching a game with wine the file could contain the below, where `gamemd-spawn.exe` is replaced with the game executable. Note that users might need to execute `chmod u+x wine-dta.sh` once to allow it to be launched. +``` +#!/bin/sh +wine gamemd-spawn.exe $* +``` - The use of `*.cur` mouse cursor files is not supported on the cross-platform `UniversalGL` build. To ensure the intended cursor is shown instead of a missing texture (pink square) all themes need to contain a `cursor.png` file. Existing `*.cur` files will still be used by the Windows-only builds. diff --git a/LICENSE.md b/LICENSE.md index ded950670..c17a6ed40 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,5 +1,5 @@ CnCNet Client -Copyright (C) 2022 CnCNet, Rampastring +Copyright (C) 2023 CnCNet, Rampastring This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -19,7 +19,7 @@ along with this program. If not, see . This software includes Rampastring.Tools and Rampastring.XNAUI. Their license follows: -Copyright (c) 2022 Rami "Rampastring" Pasanen +Copyright (c) 2023 Rami "Rampastring" Pasanen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/NuGet.config b/NuGet.config index 546b02c03..16022e7f2 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,8 +1,16 @@ - + - + + + + + + + + + \ No newline at end of file diff --git a/TranslationNotifierGenerator/TranslationNotifierGenerator.csproj b/TranslationNotifierGenerator/TranslationNotifierGenerator.csproj index e17bbe887..be08dd9a8 100644 --- a/TranslationNotifierGenerator/TranslationNotifierGenerator.csproj +++ b/TranslationNotifierGenerator/TranslationNotifierGenerator.csproj @@ -6,11 +6,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/build/AfterPublish.targets b/build/AfterPublish.targets index 7efa27848..eef4f0a72 100644 --- a/build/AfterPublish.targets +++ b/build/AfterPublish.targets @@ -54,6 +54,7 @@ + @@ -63,6 +64,8 @@ + + \ No newline at end of file diff --git a/build/CopyResources.targets b/build/CopyResources.targets index ae35d406a..ce46b0455 100644 --- a/build/CopyResources.targets +++ b/build/CopyResources.targets @@ -7,7 +7,7 @@ - +