[
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "<!--\n     This Source Code Form is subject to the terms of the Mozilla Public\n     License, v. 2.0. If a copy of the MPL was not distributed with this\n     file, You can obtain one at https://mozilla.org/MPL/2.0/.\n-->\n\n# Contributing\nAll contributions should be in the form of [pull requests](https://github.com/kernitus/BukkitOldCombatMechanics/pulls), and should use the formatting profiles provided at the bottom of this page. All pull requests must be fully functional and able to compile, and should be fully tested. Please submit **monofocal** pull requests, i.e.if you're making unrelated changes to two different modules, or decide to also update the version of a dependency, *those should be separate pull requests*.\n\n## Testing expectations\nContributions are expected to include automated test coverage within the existing test framework where practical. For behaviour changes, bug fixes, and new features, prefer adding or updating tests in the existing Kotlin integration-test harness rather than relying on manual testing alone.\n\n## Language expectations\nNew classes should be written in Kotlin by default. If you are modifying an existing Java class, keep the surrounding file consistent unless there is a clear reason to migrate it as part of the same change.\n\n## Module system\nOldCombatMechanics uses a modular system to make sure each feature is completely independent from any other, and can be toggled off and have no impact on server performance. This means each new module must extend the `Module` class, and implement a public constructor which takes an instance of the plugin and passes the module name to the superconstructor. The `Module` class also provides an overloadable `reload()` method which is called whenever the plugin is reloaded. If you are using any class-level variables they should probably be updated in here. This should also be used for any initialisation that might need to be done if the config section is changed and `ocm reload` is called. You may then call `reload()` from the constructor.\n\n## Module naming\nThe name specified in the module constructor must be the same as the one used in the config.yml. The module name must meaningfully describe the purpose of the module, for example `disable-offhand` or `old-burn-delay`. The module classes should thus be respectively named `ModuleDisableOffhand` and `ModuleOldBurnDelay`. As you can see, kebab case is used for the constructor and config name, while pascal case prefixed by `Module` is used for class names.\n\n## Module configuration\nAll module configuration is done through the Module class in a subsection of the config.yml for each module. To access config variables under the module section, you can use the methods provided by `module()`, such as `module().getBoolean(\"enableBlue\")`. The module config section must contain an `enabled` boolean key which is used by the module system to selectively register/unregister the module, and, if applicable, a `worlds: []` list key to configure which worlds your module will work in. It is the responsibility of the module to use the `module().isEnabled(world)` to make sure this is enforced.\n\n## Code style\nThere is relative freedom when it codes to coding style, but please adhere to the following guidelines:\n* Use `final` for variables whenever possible. This makes it much less likely to accidentally change a variable and improves code readability.\n* Use `if !condition return;` pattern to avoid eccessive nested if-statements. That is, to check multiple prerequisite conditions, invert the condition and return, and after returning continue with the code.\n* Use internal utilies where possible. There is a `Messenger` class for sending messages to users or console, a `Reflector` class for simple reflection, and `DualVersionedMaterial` for support across minecraft versions where the Bukkit item names have changed. There are many more in the `utilities` package, the usage of which can be found in already existing modules.\n\n## Formatting profiles\nPlease use the [default formatting profile](https://www.jetbrains.com/help/idea/reformat-and-rearrange-code.html) provided with IntelliJ. If using a different IDE, here is the [.editconfig](https://www.jetbrains.com/help/idea/configuring-code-style.html#editorconfig) file for those same settings:\n\n```\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = space\ninsert_final_newline = false\nmax_line_length = 120\ntab_width = 4\nij_continuation_indent_size = 8\nij_formatter_off_tag = @formatter:off\nij_formatter_on_tag = @formatter:on\nij_formatter_tags_enabled = false\nij_smart_tabs = false\nij_visual_guides = none\nij_wrap_on_typing = false\n\n[*.css]\nij_css_align_closing_brace_with_properties = false\nij_css_blank_lines_around_nested_selector = 1\nij_css_blank_lines_between_blocks = 1\nij_css_brace_placement = end_of_line\nij_css_enforce_quotes_on_format = false\nij_css_hex_color_long_format = false\nij_css_hex_color_lower_case = false\nij_css_hex_color_short_format = false\nij_css_hex_color_upper_case = false\nij_css_keep_blank_lines_in_code = 2\nij_css_keep_indents_on_empty_lines = false\nij_css_keep_single_line_blocks = false\nij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_css_space_after_colon = true\nij_css_space_before_opening_brace = true\nij_css_use_double_quotes = true\nij_css_value_alignment = do_not_align\n\n[*.feature]\nindent_size = 2\nij_gherkin_keep_indents_on_empty_lines = false\n\n[*.gsp]\nij_gsp_keep_indents_on_empty_lines = false\n\n[*.haml]\nindent_size = 2\nij_haml_keep_indents_on_empty_lines = false\n\n[*.java]\nij_java_align_consecutive_assignments = false\nij_java_align_consecutive_variable_declarations = false\nij_java_align_group_field_declarations = false\nij_java_align_multiline_annotation_parameters = false\nij_java_align_multiline_array_initializer_expression = false\nij_java_align_multiline_assignment = false\nij_java_align_multiline_binary_operation = false\nij_java_align_multiline_chained_methods = false\nij_java_align_multiline_extends_list = false\nij_java_align_multiline_for = true\nij_java_align_multiline_method_parentheses = false\nij_java_align_multiline_parameters = true\nij_java_align_multiline_parameters_in_calls = false\nij_java_align_multiline_parenthesized_expression = false\nij_java_align_multiline_records = true\nij_java_align_multiline_resources = true\nij_java_align_multiline_ternary_operation = false\nij_java_align_multiline_text_blocks = false\nij_java_align_multiline_throws_list = false\nij_java_align_subsequent_simple_methods = false\nij_java_align_throws_keyword = false\nij_java_annotation_parameter_wrap = off\nij_java_array_initializer_new_line_after_left_brace = false\nij_java_array_initializer_right_brace_on_new_line = false\nij_java_array_initializer_wrap = off\nij_java_assert_statement_colon_on_next_line = false\nij_java_assert_statement_wrap = off\nij_java_assignment_wrap = off\nij_java_binary_operation_sign_on_next_line = false\nij_java_binary_operation_wrap = off\nij_java_blank_lines_after_anonymous_class_header = 0\nij_java_blank_lines_after_class_header = 0\nij_java_blank_lines_after_imports = 1\nij_java_blank_lines_after_package = 1\nij_java_blank_lines_around_class = 1\nij_java_blank_lines_around_field = 0\nij_java_blank_lines_around_field_in_interface = 0\nij_java_blank_lines_around_initializer = 1\nij_java_blank_lines_around_method = 1\nij_java_blank_lines_around_method_in_interface = 1\nij_java_blank_lines_before_class_end = 0\nij_java_blank_lines_before_imports = 1\nij_java_blank_lines_before_method_body = 0\nij_java_blank_lines_before_package = 0\nij_java_block_brace_style = end_of_line\nij_java_block_comment_at_first_column = true\nij_java_call_parameters_new_line_after_left_paren = false\nij_java_call_parameters_right_paren_on_new_line = false\nij_java_call_parameters_wrap = off\nij_java_case_statement_on_separate_line = true\nij_java_catch_on_new_line = false\nij_java_class_annotation_wrap = split_into_lines\nij_java_class_brace_style = end_of_line\nij_java_class_count_to_use_import_on_demand = 5\nij_java_class_names_in_javadoc = 1\nij_java_do_not_indent_top_level_class_members = false\nij_java_do_not_wrap_after_single_annotation = false\nij_java_do_while_brace_force = never\nij_java_doc_add_blank_line_after_description = true\nij_java_doc_add_blank_line_after_param_comments = false\nij_java_doc_add_blank_line_after_return = false\nij_java_doc_add_p_tag_on_empty_lines = true\nij_java_doc_align_exception_comments = true\nij_java_doc_align_param_comments = true\nij_java_doc_do_not_wrap_if_one_line = false\nij_java_doc_enable_formatting = true\nij_java_doc_enable_leading_asterisks = true\nij_java_doc_indent_on_continuation = false\nij_java_doc_keep_empty_lines = true\nij_java_doc_keep_empty_parameter_tag = true\nij_java_doc_keep_empty_return_tag = true\nij_java_doc_keep_empty_throws_tag = true\nij_java_doc_keep_invalid_tags = true\nij_java_doc_param_description_on_new_line = false\nij_java_doc_preserve_line_breaks = false\nij_java_doc_use_throws_not_exception_tag = true\nij_java_else_on_new_line = false\nij_java_entity_dd_suffix = EJB\nij_java_entity_eb_suffix = Bean\nij_java_entity_hi_suffix = Home\nij_java_entity_lhi_prefix = Local\nij_java_entity_lhi_suffix = Home\nij_java_entity_li_prefix = Local\nij_java_entity_pk_class = java.lang.String\nij_java_entity_vo_suffix = VO\nij_java_enum_constants_wrap = off\nij_java_extends_keyword_wrap = off\nij_java_extends_list_wrap = off\nij_java_field_annotation_wrap = split_into_lines\nij_java_finally_on_new_line = false\nij_java_for_brace_force = never\nij_java_for_statement_new_line_after_left_paren = false\nij_java_for_statement_right_paren_on_new_line = false\nij_java_for_statement_wrap = off\nij_java_generate_final_locals = false\nij_java_generate_final_parameters = false\nij_java_if_brace_force = never\nij_java_imports_layout = *,|,javax.**,java.**,|,$*\nij_java_indent_case_from_switch = true\nij_java_insert_inner_class_imports = false\nij_java_insert_override_annotation = true\nij_java_keep_blank_lines_before_right_brace = 2\nij_java_keep_blank_lines_between_package_declaration_and_header = 2\nij_java_keep_blank_lines_in_code = 2\nij_java_keep_blank_lines_in_declarations = 2\nij_java_keep_control_statement_in_one_line = true\nij_java_keep_first_column_comment = true\nij_java_keep_indents_on_empty_lines = false\nij_java_keep_line_breaks = true\nij_java_keep_multiple_expressions_in_one_line = false\nij_java_keep_simple_blocks_in_one_line = false\nij_java_keep_simple_classes_in_one_line = false\nij_java_keep_simple_lambdas_in_one_line = false\nij_java_keep_simple_methods_in_one_line = false\nij_java_label_indent_absolute = false\nij_java_label_indent_size = 0\nij_java_lambda_brace_style = end_of_line\nij_java_layout_static_imports_separately = true\nij_java_line_comment_add_space = false\nij_java_line_comment_at_first_column = true\nij_java_message_dd_suffix = EJB\nij_java_message_eb_suffix = Bean\nij_java_method_annotation_wrap = split_into_lines\nij_java_method_brace_style = end_of_line\nij_java_method_call_chain_wrap = off\nij_java_method_parameters_new_line_after_left_paren = false\nij_java_method_parameters_right_paren_on_new_line = false\nij_java_method_parameters_wrap = off\nij_java_modifier_list_wrap = false\nij_java_names_count_to_use_import_on_demand = 3\nij_java_new_line_after_lparen_in_record_header = false\nij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.*\nij_java_parameter_annotation_wrap = off\nij_java_parentheses_expression_new_line_after_left_paren = false\nij_java_parentheses_expression_right_paren_on_new_line = false\nij_java_place_assignment_sign_on_next_line = false\nij_java_prefer_longer_names = true\nij_java_prefer_parameters_wrap = false\nij_java_record_components_wrap = normal\nij_java_repeat_synchronized = true\nij_java_replace_instanceof_and_cast = false\nij_java_replace_null_check = true\nij_java_replace_sum_lambda_with_method_ref = true\nij_java_resource_list_new_line_after_left_paren = false\nij_java_resource_list_right_paren_on_new_line = false\nij_java_resource_list_wrap = off\nij_java_rparen_on_new_line_in_record_header = false\nij_java_session_dd_suffix = EJB\nij_java_session_eb_suffix = Bean\nij_java_session_hi_suffix = Home\nij_java_session_lhi_prefix = Local\nij_java_session_lhi_suffix = Home\nij_java_session_li_prefix = Local\nij_java_session_si_suffix = Service\nij_java_space_after_closing_angle_bracket_in_type_argument = false\nij_java_space_after_colon = true\nij_java_space_after_comma = true\nij_java_space_after_comma_in_type_arguments = true\nij_java_space_after_for_semicolon = true\nij_java_space_after_quest = true\nij_java_space_after_type_cast = true\nij_java_space_before_annotation_array_initializer_left_brace = false\nij_java_space_before_annotation_parameter_list = false\nij_java_space_before_array_initializer_left_brace = false\nij_java_space_before_catch_keyword = true\nij_java_space_before_catch_left_brace = true\nij_java_space_before_catch_parentheses = true\nij_java_space_before_class_left_brace = true\nij_java_space_before_colon = true\nij_java_space_before_colon_in_foreach = true\nij_java_space_before_comma = false\nij_java_space_before_do_left_brace = true\nij_java_space_before_else_keyword = true\nij_java_space_before_else_left_brace = true\nij_java_space_before_finally_keyword = true\nij_java_space_before_finally_left_brace = true\nij_java_space_before_for_left_brace = true\nij_java_space_before_for_parentheses = true\nij_java_space_before_for_semicolon = false\nij_java_space_before_if_left_brace = true\nij_java_space_before_if_parentheses = true\nij_java_space_before_method_call_parentheses = false\nij_java_space_before_method_left_brace = true\nij_java_space_before_method_parentheses = false\nij_java_space_before_opening_angle_bracket_in_type_parameter = false\nij_java_space_before_quest = true\nij_java_space_before_switch_left_brace = true\nij_java_space_before_switch_parentheses = true\nij_java_space_before_synchronized_left_brace = true\nij_java_space_before_synchronized_parentheses = true\nij_java_space_before_try_left_brace = true\nij_java_space_before_try_parentheses = true\nij_java_space_before_type_parameter_list = false\nij_java_space_before_while_keyword = true\nij_java_space_before_while_left_brace = true\nij_java_space_before_while_parentheses = true\nij_java_space_inside_one_line_enum_braces = false\nij_java_space_within_empty_array_initializer_braces = false\nij_java_space_within_empty_method_call_parentheses = false\nij_java_space_within_empty_method_parentheses = false\nij_java_spaces_around_additive_operators = true\nij_java_spaces_around_assignment_operators = true\nij_java_spaces_around_bitwise_operators = true\nij_java_spaces_around_equality_operators = true\nij_java_spaces_around_lambda_arrow = true\nij_java_spaces_around_logical_operators = true\nij_java_spaces_around_method_ref_dbl_colon = false\nij_java_spaces_around_multiplicative_operators = true\nij_java_spaces_around_relational_operators = true\nij_java_spaces_around_shift_operators = true\nij_java_spaces_around_type_bounds_in_type_parameters = true\nij_java_spaces_around_unary_operator = false\nij_java_spaces_within_angle_brackets = false\nij_java_spaces_within_annotation_parentheses = false\nij_java_spaces_within_array_initializer_braces = false\nij_java_spaces_within_braces = false\nij_java_spaces_within_brackets = false\nij_java_spaces_within_cast_parentheses = false\nij_java_spaces_within_catch_parentheses = false\nij_java_spaces_within_for_parentheses = false\nij_java_spaces_within_if_parentheses = false\nij_java_spaces_within_method_call_parentheses = false\nij_java_spaces_within_method_parentheses = false\nij_java_spaces_within_parentheses = false\nij_java_spaces_within_record_header = false\nij_java_spaces_within_switch_parentheses = false\nij_java_spaces_within_synchronized_parentheses = false\nij_java_spaces_within_try_parentheses = false\nij_java_spaces_within_while_parentheses = false\nij_java_special_else_if_treatment = true\nij_java_subclass_name_suffix = Impl\nij_java_ternary_operation_signs_on_next_line = false\nij_java_ternary_operation_wrap = off\nij_java_test_name_suffix = Test\nij_java_throws_keyword_wrap = off\nij_java_throws_list_wrap = off\nij_java_use_external_annotations = false\nij_java_use_fq_class_names = false\nij_java_use_relative_indents = false\nij_java_use_single_class_imports = true\nij_java_variable_annotation_wrap = off\nij_java_visibility = public\nij_java_while_brace_force = never\nij_java_while_on_new_line = false\nij_java_wrap_comments = false\nij_java_wrap_first_method_in_call_chain = false\nij_java_wrap_long_lines = false\n\n[.editorconfig]\nij_editorconfig_align_group_field_declarations = false\nij_editorconfig_space_after_colon = false\nij_editorconfig_space_after_comma = true\nij_editorconfig_space_before_colon = false\nij_editorconfig_space_before_comma = false\nij_editorconfig_spaces_around_assignment_operators = true\n\n[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]\nij_xml_align_attributes = true\nij_xml_align_text = false\nij_xml_attribute_wrap = normal\nij_xml_block_comment_at_first_column = true\nij_xml_keep_blank_lines = 2\nij_xml_keep_indents_on_empty_lines = false\nij_xml_keep_line_breaks = true\nij_xml_keep_line_breaks_in_text = true\nij_xml_keep_whitespaces = false\nij_xml_keep_whitespaces_around_cdata = preserve\nij_xml_keep_whitespaces_inside_cdata = false\nij_xml_line_comment_at_first_column = true\nij_xml_space_after_tag_name = false\nij_xml_space_around_equals_in_attribute = false\nij_xml_space_inside_empty_tag = false\nij_xml_text_wrap = normal\nij_xml_use_custom_settings = false\n\n[{*.ft,*.vm,*.vsl}]\nij_vtl_keep_indents_on_empty_lines = false\n\n[{*.gant,*.gradle,*.groovy,*.gson,*.gy}]\nij_groovy_align_group_field_declarations = false\nij_groovy_align_multiline_array_initializer_expression = false\nij_groovy_align_multiline_assignment = false\nij_groovy_align_multiline_binary_operation = false\nij_groovy_align_multiline_chained_methods = false\nij_groovy_align_multiline_extends_list = false\nij_groovy_align_multiline_for = true\nij_groovy_align_multiline_list_or_map = true\nij_groovy_align_multiline_method_parentheses = false\nij_groovy_align_multiline_parameters = true\nij_groovy_align_multiline_parameters_in_calls = false\nij_groovy_align_multiline_resources = true\nij_groovy_align_multiline_ternary_operation = false\nij_groovy_align_multiline_throws_list = false\nij_groovy_align_named_args_in_map = true\nij_groovy_align_throws_keyword = false\nij_groovy_array_initializer_new_line_after_left_brace = false\nij_groovy_array_initializer_right_brace_on_new_line = false\nij_groovy_array_initializer_wrap = off\nij_groovy_assert_statement_wrap = off\nij_groovy_assignment_wrap = off\nij_groovy_binary_operation_wrap = off\nij_groovy_blank_lines_after_class_header = 0\nij_groovy_blank_lines_after_imports = 1\nij_groovy_blank_lines_after_package = 1\nij_groovy_blank_lines_around_class = 1\nij_groovy_blank_lines_around_field = 0\nij_groovy_blank_lines_around_field_in_interface = 0\nij_groovy_blank_lines_around_method = 1\nij_groovy_blank_lines_around_method_in_interface = 1\nij_groovy_blank_lines_before_imports = 1\nij_groovy_blank_lines_before_method_body = 0\nij_groovy_blank_lines_before_package = 0\nij_groovy_block_brace_style = end_of_line\nij_groovy_block_comment_at_first_column = true\nij_groovy_call_parameters_new_line_after_left_paren = false\nij_groovy_call_parameters_right_paren_on_new_line = false\nij_groovy_call_parameters_wrap = off\nij_groovy_catch_on_new_line = false\nij_groovy_class_annotation_wrap = split_into_lines\nij_groovy_class_brace_style = end_of_line\nij_groovy_class_count_to_use_import_on_demand = 5\nij_groovy_do_while_brace_force = never\nij_groovy_else_on_new_line = false\nij_groovy_enum_constants_wrap = off\nij_groovy_extends_keyword_wrap = off\nij_groovy_extends_list_wrap = off\nij_groovy_field_annotation_wrap = split_into_lines\nij_groovy_finally_on_new_line = false\nij_groovy_for_brace_force = never\nij_groovy_for_statement_new_line_after_left_paren = false\nij_groovy_for_statement_right_paren_on_new_line = false\nij_groovy_for_statement_wrap = off\nij_groovy_if_brace_force = never\nij_groovy_import_annotation_wrap = 2\nij_groovy_imports_layout = *,|,javax.**,java.**,|,$*\nij_groovy_indent_case_from_switch = true\nij_groovy_indent_label_blocks = true\nij_groovy_insert_inner_class_imports = false\nij_groovy_keep_blank_lines_before_right_brace = 2\nij_groovy_keep_blank_lines_in_code = 2\nij_groovy_keep_blank_lines_in_declarations = 2\nij_groovy_keep_control_statement_in_one_line = true\nij_groovy_keep_first_column_comment = true\nij_groovy_keep_indents_on_empty_lines = false\nij_groovy_keep_line_breaks = true\nij_groovy_keep_multiple_expressions_in_one_line = false\nij_groovy_keep_simple_blocks_in_one_line = false\nij_groovy_keep_simple_classes_in_one_line = true\nij_groovy_keep_simple_lambdas_in_one_line = true\nij_groovy_keep_simple_methods_in_one_line = true\nij_groovy_label_indent_absolute = false\nij_groovy_label_indent_size = 0\nij_groovy_lambda_brace_style = end_of_line\nij_groovy_layout_static_imports_separately = true\nij_groovy_line_comment_add_space = false\nij_groovy_line_comment_at_first_column = true\nij_groovy_method_annotation_wrap = split_into_lines\nij_groovy_method_brace_style = end_of_line\nij_groovy_method_call_chain_wrap = off\nij_groovy_method_parameters_new_line_after_left_paren = false\nij_groovy_method_parameters_right_paren_on_new_line = false\nij_groovy_method_parameters_wrap = off\nij_groovy_modifier_list_wrap = false\nij_groovy_names_count_to_use_import_on_demand = 3\nij_groovy_parameter_annotation_wrap = off\nij_groovy_parentheses_expression_new_line_after_left_paren = false\nij_groovy_parentheses_expression_right_paren_on_new_line = false\nij_groovy_prefer_parameters_wrap = false\nij_groovy_resource_list_new_line_after_left_paren = false\nij_groovy_resource_list_right_paren_on_new_line = false\nij_groovy_resource_list_wrap = off\nij_groovy_space_after_assert_separator = true\nij_groovy_space_after_colon = true\nij_groovy_space_after_comma = true\nij_groovy_space_after_comma_in_type_arguments = true\nij_groovy_space_after_for_semicolon = true\nij_groovy_space_after_quest = true\nij_groovy_space_after_type_cast = true\nij_groovy_space_before_annotation_parameter_list = false\nij_groovy_space_before_array_initializer_left_brace = false\nij_groovy_space_before_assert_separator = false\nij_groovy_space_before_catch_keyword = true\nij_groovy_space_before_catch_left_brace = true\nij_groovy_space_before_catch_parentheses = true\nij_groovy_space_before_class_left_brace = true\nij_groovy_space_before_closure_left_brace = true\nij_groovy_space_before_colon = true\nij_groovy_space_before_comma = false\nij_groovy_space_before_do_left_brace = true\nij_groovy_space_before_else_keyword = true\nij_groovy_space_before_else_left_brace = true\nij_groovy_space_before_finally_keyword = true\nij_groovy_space_before_finally_left_brace = true\nij_groovy_space_before_for_left_brace = true\nij_groovy_space_before_for_parentheses = true\nij_groovy_space_before_for_semicolon = false\nij_groovy_space_before_if_left_brace = true\nij_groovy_space_before_if_parentheses = true\nij_groovy_space_before_method_call_parentheses = false\nij_groovy_space_before_method_left_brace = true\nij_groovy_space_before_method_parentheses = false\nij_groovy_space_before_quest = true\nij_groovy_space_before_switch_left_brace = true\nij_groovy_space_before_switch_parentheses = true\nij_groovy_space_before_synchronized_left_brace = true\nij_groovy_space_before_synchronized_parentheses = true\nij_groovy_space_before_try_left_brace = true\nij_groovy_space_before_try_parentheses = true\nij_groovy_space_before_while_keyword = true\nij_groovy_space_before_while_left_brace = true\nij_groovy_space_before_while_parentheses = true\nij_groovy_space_in_named_argument = true\nij_groovy_space_in_named_argument_before_colon = false\nij_groovy_space_within_empty_array_initializer_braces = false\nij_groovy_space_within_empty_method_call_parentheses = false\nij_groovy_spaces_around_additive_operators = true\nij_groovy_spaces_around_assignment_operators = true\nij_groovy_spaces_around_bitwise_operators = true\nij_groovy_spaces_around_equality_operators = true\nij_groovy_spaces_around_lambda_arrow = true\nij_groovy_spaces_around_logical_operators = true\nij_groovy_spaces_around_multiplicative_operators = true\nij_groovy_spaces_around_regex_operators = true\nij_groovy_spaces_around_relational_operators = true\nij_groovy_spaces_around_shift_operators = true\nij_groovy_spaces_within_annotation_parentheses = false\nij_groovy_spaces_within_array_initializer_braces = false\nij_groovy_spaces_within_braces = true\nij_groovy_spaces_within_brackets = false\nij_groovy_spaces_within_cast_parentheses = false\nij_groovy_spaces_within_catch_parentheses = false\nij_groovy_spaces_within_for_parentheses = false\nij_groovy_spaces_within_gstring_injection_braces = false\nij_groovy_spaces_within_if_parentheses = false\nij_groovy_spaces_within_list_or_map = false\nij_groovy_spaces_within_method_call_parentheses = false\nij_groovy_spaces_within_method_parentheses = false\nij_groovy_spaces_within_parentheses = false\nij_groovy_spaces_within_switch_parentheses = false\nij_groovy_spaces_within_synchronized_parentheses = false\nij_groovy_spaces_within_try_parentheses = false\nij_groovy_spaces_within_tuple_expression = false\nij_groovy_spaces_within_while_parentheses = false\nij_groovy_special_else_if_treatment = true\nij_groovy_ternary_operation_wrap = off\nij_groovy_throws_keyword_wrap = off\nij_groovy_throws_list_wrap = off\nij_groovy_use_flying_geese_braces = false\nij_groovy_use_fq_class_names = false\nij_groovy_use_fq_class_names_in_javadoc = true\nij_groovy_use_relative_indents = false\nij_groovy_use_single_class_imports = true\nij_groovy_variable_annotation_wrap = off\nij_groovy_while_brace_force = never\nij_groovy_while_on_new_line = false\nij_groovy_wrap_long_lines = false\n\n[{*.gradle.kts,*.kt,*.kts,*.main.kts}]\nij_kotlin_align_in_columns_case_branch = false\nij_kotlin_align_multiline_binary_operation = false\nij_kotlin_align_multiline_extends_list = false\nij_kotlin_align_multiline_method_parentheses = false\nij_kotlin_align_multiline_parameters = true\nij_kotlin_align_multiline_parameters_in_calls = false\nij_kotlin_allow_trailing_comma = false\nij_kotlin_allow_trailing_comma_on_call_site = false\nij_kotlin_assignment_wrap = normal\nij_kotlin_blank_lines_after_class_header = 0\nij_kotlin_blank_lines_around_block_when_branches = 0\nij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1\nij_kotlin_block_comment_at_first_column = true\nij_kotlin_call_parameters_new_line_after_left_paren = true\nij_kotlin_call_parameters_right_paren_on_new_line = true\nij_kotlin_call_parameters_wrap = on_every_item\nij_kotlin_catch_on_new_line = false\nij_kotlin_class_annotation_wrap = split_into_lines\nij_kotlin_code_style_defaults = KOTLIN_OFFICIAL\nij_kotlin_continuation_indent_for_chained_calls = false\nij_kotlin_continuation_indent_for_expression_bodies = false\nij_kotlin_continuation_indent_in_argument_lists = false\nij_kotlin_continuation_indent_in_elvis = false\nij_kotlin_continuation_indent_in_if_conditions = false\nij_kotlin_continuation_indent_in_parameter_lists = false\nij_kotlin_continuation_indent_in_supertype_lists = false\nij_kotlin_else_on_new_line = false\nij_kotlin_enum_constants_wrap = off\nij_kotlin_extends_list_wrap = normal\nij_kotlin_field_annotation_wrap = split_into_lines\nij_kotlin_finally_on_new_line = false\nij_kotlin_if_rparen_on_new_line = true\nij_kotlin_import_nested_classes = false\nij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^\nij_kotlin_insert_whitespaces_in_simple_one_line_method = true\nij_kotlin_keep_blank_lines_before_right_brace = 2\nij_kotlin_keep_blank_lines_in_code = 2\nij_kotlin_keep_blank_lines_in_declarations = 2\nij_kotlin_keep_first_column_comment = true\nij_kotlin_keep_indents_on_empty_lines = false\nij_kotlin_keep_line_breaks = true\nij_kotlin_lbrace_on_next_line = false\nij_kotlin_line_comment_add_space = false\nij_kotlin_line_comment_at_first_column = true\nij_kotlin_method_annotation_wrap = split_into_lines\nij_kotlin_method_call_chain_wrap = normal\nij_kotlin_method_parameters_new_line_after_left_paren = true\nij_kotlin_method_parameters_right_paren_on_new_line = true\nij_kotlin_method_parameters_wrap = on_every_item\nij_kotlin_name_count_to_use_star_import = 5\nij_kotlin_name_count_to_use_star_import_for_members = 3\nij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**\nij_kotlin_parameter_annotation_wrap = off\nij_kotlin_space_after_comma = true\nij_kotlin_space_after_extend_colon = true\nij_kotlin_space_after_type_colon = true\nij_kotlin_space_before_catch_parentheses = true\nij_kotlin_space_before_comma = false\nij_kotlin_space_before_extend_colon = true\nij_kotlin_space_before_for_parentheses = true\nij_kotlin_space_before_if_parentheses = true\nij_kotlin_space_before_lambda_arrow = true\nij_kotlin_space_before_type_colon = false\nij_kotlin_space_before_when_parentheses = true\nij_kotlin_space_before_while_parentheses = true\nij_kotlin_spaces_around_additive_operators = true\nij_kotlin_spaces_around_assignment_operators = true\nij_kotlin_spaces_around_equality_operators = true\nij_kotlin_spaces_around_function_type_arrow = true\nij_kotlin_spaces_around_logical_operators = true\nij_kotlin_spaces_around_multiplicative_operators = true\nij_kotlin_spaces_around_range = false\nij_kotlin_spaces_around_relational_operators = true\nij_kotlin_spaces_around_unary_operator = false\nij_kotlin_spaces_around_when_arrow = true\nij_kotlin_variable_annotation_wrap = off\nij_kotlin_while_on_new_line = false\nij_kotlin_wrap_elvis_expressions = 1\nij_kotlin_wrap_expression_body_functions = 1\nij_kotlin_wrap_first_method_in_call_chain = false\n\n[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}]\nij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3\nij_html_align_attributes = true\nij_html_align_text = false\nij_html_attribute_wrap = normal\nij_html_block_comment_at_first_column = true\nij_html_do_not_align_children_of_min_lines = 0\nij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p\nij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot\nij_html_enforce_quotes = false\nij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var\nij_html_keep_blank_lines = 2\nij_html_keep_indents_on_empty_lines = false\nij_html_keep_line_breaks = true\nij_html_keep_line_breaks_in_text = true\nij_html_keep_whitespaces = false\nij_html_keep_whitespaces_inside = span,pre,textarea\nij_html_line_comment_at_first_column = true\nij_html_new_line_after_last_attribute = never\nij_html_new_line_before_first_attribute = never\nij_html_quote_style = double\nij_html_remove_new_line_before_tags = br\nij_html_space_after_tag_name = false\nij_html_space_around_equality_in_attribute = false\nij_html_space_inside_empty_tag = false\nij_html_text_wrap = normal\nij_html_uniform_ident = false\n\n[{*.markdown,*.md}]\nij_markdown_force_one_space_after_blockquote_symbol = true\nij_markdown_force_one_space_after_header_symbol = true\nij_markdown_force_one_space_after_list_bullet = true\nij_markdown_force_one_space_between_words = true\nij_markdown_keep_indents_on_empty_lines = false\nij_markdown_max_lines_around_block_elements = 1\nij_markdown_max_lines_around_header = 1\nij_markdown_max_lines_between_paragraphs = 1\nij_markdown_min_lines_around_block_elements = 1\nij_markdown_min_lines_around_header = 1\nij_markdown_min_lines_between_paragraphs = 1\n\n[{*.properties,spring.handlers,spring.schemas}]\nij_properties_align_group_field_declarations = false\nij_properties_keep_blank_lines = false\nij_properties_key_value_delimiter = equals\nij_properties_spaces_around_key_value_delimiter = false\n\n[{*.yaml,*.yml}]\nindent_size = 2\nij_yaml_keep_indents_on_empty_lines = false\nij_yaml_keep_line_breaks = true\nij_yaml_space_before_colon = true\nij_yaml_spaces_within_braces = true\nij_yaml_spaces_within_brackets = true\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1-bug-report.yaml",
    "content": "name: Bug Report\ndescription: Report a bug in the plugin\nlabels: [\"bug\", \"investigate\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️\n        \n        1. Try the [latest test version](https://hangar.papermc.io/kernitus/OldCombatMechanics/versions?channel=Snapshot&platform=PAPER) first\n        2. Complete ALL fields below - incomplete reports will be CLOSED\n        3. This is a volunteer project - we have no obligation to help incomplete reports\n        4. Have a question? Please use the Question template instead\n  \n  - type: input\n    id: server-version\n    attributes:\n      label: Server Version\n      description: Version of the server, e.g. Spigot 1.14.1 or Paper 1.19.3\n      placeholder: e.g. Paper 1.20.4\n    validations:\n      required: true\n  \n  - type: input\n    id: ocm-version\n    attributes:\n      label: OldCombatMechanics Version\n      description: \"EXACT version of OldCombatMechanics, e.g. 1.7.2 or 2.1.1-beta+e2f0369. DO NOT write 'latest' - versions change often.\"\n      placeholder: e.g. 2.1.1-beta+e2f0369\n    validations:\n      required: true\n  \n  - type: textarea\n    id: server-log\n    attributes:\n      label: Server Log File\n      description: Console log from the server. DO NOT use an external service like pastebin, as these expire.\n      placeholder: Paste your log here\n      render: console\n    validations:\n      required: true\n  \n  - type: textarea\n    id: config\n    attributes:\n      label: OldCombatMechanics config.yml\n      description: Your config file. DO NOT use an external service like pastebin, as these expire.\n      placeholder: Paste your config.yml here\n      render: yaml\n    validations:\n      required: true\n  \n  - type: textarea\n    id: other-plugins\n    attributes:\n      label: Other Plugins\n      description: List of other plugins installed (conflicts are common)\n      placeholder: |\n        - ViaVersion 1.0.0\n        - AnotherPlugin 2.3.1\n    validations:\n      required: true\n  \n  - type: textarea\n    id: problem-description\n    attributes:\n      label: Problem Description\n      description: A clear and concise description of what the bug is\n    validations:\n      required: true\n  \n  - type: textarea\n    id: reproduce\n    attributes:\n      label: Steps to Reproduce\n      description: What you would do in order for the problem to occur\n      placeholder: |\n        1. Hit a mob with a stick\n        2. \n        3. \n    validations:\n      required: true\n  \n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behaviour\n      description: What do you think should happen when you perform the above steps?\n    validations:\n      required: true\n  \n  - type: textarea\n    id: actual\n    attributes:\n      label: Actual Behaviour\n      description: What does happen when you perform the above steps?\n    validations:\n      required: true\n  \n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional Context\n      description: Add any other context, screenshots, or videos here\n\n  - type: checkboxes\n    id: confirmation\n    attributes:\n      label: Pre-submission checklist\n      options:\n        - label: I have tried the latest test/snapshot version\n          required: true\n        - label: I have filled out all required fields below\n          required: true\n  \n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2-question.yaml",
    "content": "name: Question\ndescription: Ask a question about the plugin\nlabels: [\"question\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️\n        \n        1. Check the readme and wiki first: https://github.com/kernitus/BukkitOldCombatMechanics/wiki\n        2. Search existing issues to see if your question has been answered\n        3. This is a volunteer project - we have no obligation to help incomplete reports\n        4. If you've found a bug, use the Bug Report template instead\n        5. If you want a new feature, use the Feature Request template instead\n  \n  - type: checkboxes\n    id: confirmation\n    attributes:\n      label: Pre-submission checklist\n      options:\n        - label: I have checked the wiki and readme\n          required: true\n        - label: I have searched existing issues\n          required: true\n  \n  - type: textarea\n    id: question\n    attributes:\n      label: Your Question\n      description: Clearly describe what you want to know\n    validations:\n      required: true\n  \n  - type: textarea\n    id: tried\n    attributes:\n      label: What I've Tried\n      description: Have you checked documentation? Tried anything? Searched for similar issues?\n    validations:\n      required: true\n  \n  - type: input\n    id: server-version\n    attributes:\n      label: Server Version\n      placeholder: e.g. Paper 1.20.4\n    validations:\n      required: true\n  \n  - type: input\n    id: ocm-version\n    attributes:\n      label: OldCombatMechanics Version\n      description: \"DO NOT write 'latest' - specify the exact version number\"\n      placeholder: e.g. 2.1.1\n    validations:\n      required: true\n  \n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Any other relevant information, screenshots, config snippets, etc.\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/3-feature.yaml",
    "content": "name: Feature Request\ndescription: Suggest an idea for this project\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️\n        \n        1. Search for existing enhancement requests first\n        2. Have a question? Please use the Question template instead\n        3. This plugin is about combat mechanics changes from 1.9+ - not general Minecraft features\n        4. Incomplete requests WILL be closed - this is a volunteer project\n        \n        Remember that this plugin is about changes in combat mechanics following 1.8, anything else is out of scope.\n  \n  - type: checkboxes\n    id: confirmation\n    attributes:\n      label: Pre-submission checklist\n      options:\n        - label: I have searched for existing enhancement requests\n          required: true\n        - label: This relates to combat mechanics changes from 1.9+\n          required: true\n  \n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem\n      description: Describe the problem you are facing, as a consequence of a lack of features rather than a bug\n    validations:\n      required: true\n  \n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed Solution\n      description: The solution you think best for the problem\n    validations:\n      required: true\n  \n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternative Solutions\n      description: Describe alternative solutions you have considered\n    validations:\n      required: false\n  \n  - type: textarea\n    id: evidence\n    attributes:\n      label: Evidence of Mechanic Change\n      description: If your enhancement is about a combat feature that changed between 1.8 and now, provide evidence (wiki links, videos, etc.)\n      placeholder: Links to Minecraft wiki, videos demonstrating the mechanic, etc.\n    validations:\n      required: false\n  \n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional Context\n      description: Any other context or screenshots that could be relevant\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/release-please-config.json",
    "content": "{\n  \"$schema\": \"https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json\",\n  \"include-component-in-tag\": false,\n  \"bump-minor-pre-major\": true,\n  \"bootstrap-sha\": \"56dd66a4bf88f252bb063b91835d89a1a1a2e442\",\n  \"packages\": {\n    \".\": {\n      \"package-name\": \"OldCombatMechanics\",\n      \"release-type\": \"simple\",\n      \"extra-files\": [\n        {\n          \"type\": \"generic\",\n          \"path\": \"build.gradle.kts\"\n        }\n      ],\n      \"changelog-sections\": [\n        {\"type\": \"feat\", \"section\": \"Features\"},\n        {\"type\": \"fix\", \"section\": \"Bug Fixes\"},\n        {\"type\": \"perf\", \"section\": \"Performance\"}\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": ".github/release-please-manifest.json",
    "content": "{\".\":\"2.4.0\"}\n"
  },
  {
    "path": ".github/workflows/build-upload-release.yml",
    "content": "name: Build and Release\n\non:\n  release:\n    types: [published]\n\njobs:\n  build:\n    # This permission is still required by the new action\n    permissions:\n      contents: write\n\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v4\n\n    - name: Set up JDK 17\n      uses: actions/setup-java@v4\n      with:\n        java-version: '17'\n        distribution: 'adopt'\n\n    - name: Setup Gradle\n      uses: gradle/actions/setup-gradle@v4\n      with:\n        gradle-version: wrapper\n\n    # Build Step - No changes needed here\n    - name: Run Gradle Build\n      run: |\n        if [ \"${{ github.event_name }}\" == \"release\" ]; then\n          VERSION=${{ github.event.release.tag_name }}\n          VERSION=${VERSION#v}  # Strip 'v' if present\n          ./gradlew clean build -Pversion=$VERSION\n        else\n          ./gradlew clean build\n        fi\n        \n    - name: Upload Artifact to GitHub Release\n      if: ${{ github.event_name == 'release' }}\n      uses: softprops/action-gh-release@v2\n      with:\n        # The 'files' input takes a path to the asset(s) you want to upload.\n        # Keep a stable filename for external download links.\n        files: ./build/libs/OldCombatMechanics.jar\n\n    - name: Read game versions from gradle.properties\n      run: |\n        RAW=$(grep ^gameVersions gradle.properties | cut -d'=' -f2-)\n        # Convert \"1.21, 1.20.6\" -> \"1:1.21,1:1.20.6\" to select Bukkit-compatible typeId 1 entries via the minecraft endpoint\n        GAME_VERSIONS=$(echo \"$RAW\" | tr -d ' ' | awk -F',' '{\n          out=\"\";\n          for (i=1; i<=NF; i++) {\n            v=$i;\n            if (v!= \"\") {\n              if (out!= \"\") out=out \",\";\n              out=out \"1:\" v;\n            }\n          }\n          print out;\n        }')\n        echo \"GAME_VERSIONS=$GAME_VERSIONS\" >> \"$GITHUB_ENV\"\n\n    - name: Upload to CurseForge (Bukkit)\n      if: ${{ github.event_name == 'release' }}\n      uses: itsmeow/curseforge-upload@v3\n      with:\n        token: ${{ secrets.DBO_UPLOAD_API_TOKEN }}\n        project_id: '98233'\n        game_endpoint: 'minecraft'\n        file_path: './build/libs/OldCombatMechanics.jar'\n        changelog: ${{ github.event.release.body }}\n        changelog_type: 'markdown'\n        release_type: 'release'\n        display_name: 'OldCombatMechanics ${{ github.event.release.tag_name }}'\n        game_versions: ${{ env.GAME_VERSIONS }}\n\n\n    - name: Publish to Hangar\n      env:\n        HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}\n        HANGAR_CHANGELOG: ${{ github.event.release.body }}\n      run: ./gradlew build publishPluginPublicationToHangar --stacktrace\n"
  },
  {
    "path": ".github/workflows/dev-builds.yml",
    "content": "name: Dev builds\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches-ignore:\n      - 'ingametesting'\n      \njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@v4\n      with:\n        fetch-depth: 0\n\n    - name: Set up JDK 17\n      uses: actions/setup-java@v4\n      with:\n        java-version: '17'\n        distribution: 'adopt'\n        cache: gradle\n\n    - name: Setup Gradle\n      uses: gradle/actions/setup-gradle@v4\n      with:\n        gradle-version: wrapper\n\n    - name: Determine if this is a release version\n      run: |\n        IS_RELEASE=$(./gradlew -q printIsRelease)\n        echo \"Is release? $IS_RELEASE\"\n        echo \"IS_RELEASE=$IS_RELEASE\" >> $GITHUB_ENV\n\n    - name: Run Gradle Build\n      run: |\n        ./gradlew clean build\n\n    - name: Archive jar file\n      uses: actions/upload-artifact@v4\n      with:\n        name: OldCombatMechanics\n        path: build/libs/OldCombatMechanics.jar\n\n    - name: Publish Snapshot to Hangar\n      if: ${{ github.event_name == 'push' && github.event.pull_request == null && env.IS_RELEASE != 'true' }}\n      env:\n        HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}\n      run: |\n        ./gradlew publishPluginPublicationToHangar\n"
  },
  {
    "path": ".github/workflows/release-please.yml",
    "content": "name: Release Please\n\non:\n  push:\n    branches:\n      - master\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: release-please\n        uses: googleapis/release-please-action@v4\n        with:\n          token: ${{ secrets.RELEASE_PLEASE_TOKEN }}\n          config-file: .github/release-please-config.json\n          manifest-file: .github/release-please-manifest.json\n"
  },
  {
    "path": ".github/workflows/wrap-issue-form-codeblocks.yml",
    "content": "name: Wrap issue form code blocks\n\non:\n  issues:\n    types: [opened, edited]\n\npermissions:\n  issues: write\n\njobs:\n  wrap:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/github-script@v8\n        with:\n          script: |\n            // Avoid loops: when this workflow updates the issue, it triggers \"edited\" again.\n            if (context.actor === \"github-actions[bot]\") return;\n\n            const issue = context.payload.issue;\n            if (!issue || !issue.body) return;\n\n            // On edits, bail unless the body actually changed\n            if (context.payload.action === \"edited\") {\n              const changed = context.payload.changes && context.payload.changes.body;\n              if (!changed) return;\n            }\n\n            function escapeRegExp(s) {\n              return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n            }\n\n            function wrapFirstCodeBlockAfterHeading(body, headingText, summaryText) {\n              const re = new RegExp(\n                `(^###\\\\s+${escapeRegExp(headingText)}\\\\s*\\\\n+)(\\`\\`\\`[\\\\s\\\\S]*?\\\\n\\`\\`\\`)(?=\\\\n|$)`,\n                \"m\"\n              );\n\n              return body.replace(re, (_m, heading, codeblock) => {\n                return (\n                  `${heading}` +\n                  `<details>\\n` +\n                  `<summary>${summaryText}</summary>\\n\\n` +\n                  `${codeblock}\\n\\n` +\n                  `</details>`\n                );\n              });\n            }\n\n            let body = issue.body;\n\n            body = wrapFirstCodeBlockAfterHeading(body, \"Server Log File\", \"Server log\");\n            body = wrapFirstCodeBlockAfterHeading(body, \"OldCombatMechanics config.yml\", \"config.yml\");\n\n            if (body === issue.body) return;\n\n            await github.rest.issues.update({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: issue.number,\n              body\n            });\n\n"
  },
  {
    "path": ".gitignore",
    "content": "# ===\n# == IDE settings files\n# ===\n\n# IntelliJ\n/*.eml\n/*.iml\n/.idea\n\n# Eclipse\n/.project\n/.classpath\n/.settings\n\n# ===\n# == Compilation output / working files\n# ===\nout\nbuilds\ntarget\nMETA-INF\n/build/\n/gradle/\n/.gradle/\n.gradle-user/\n/.gradle-cache/\n/.gradle-local/\n\nkls-classpath\nkls_database.db\n/run/\n/bin/\n.opencode/\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\n**Prime directive:** If you are reading this file, it is your responsibility as an agent to keep it up to date with any changes you make in this repository.\n\nThis file captures repo-specific context discovered while working on this branch.\n\n## Repo overview\n- Project: OldCombatMechanics (Bukkit/Paper plugin)\n- Branch context: working from `kotlin-tests` branch\n- Build tool: Gradle (wrapper currently at 9.2.1)\n- JDKs used locally: 8, 11, 17, 25\n\n## Integration test harness (Kotlin)\n- Integration tests live in `src/integrationTest/kotlin` and are packaged into `OldCombatMechanics-<version>-tests.jar`.\n- Entrypoint plugin class: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.\n- Tests run inside a real Paper server started by the Gradle `run-paper` plugin.\n- RunServer output is redirected to `build/integration-test-logs/<version>.log`; `checkTestResults<version>` prints only a compact summary/failures to the console.\n- `KotestRunner` writes a compact `plugins/OldCombatMechanicsTest/test-failures.txt` file (up to 25 failures) so CI can surface failure reasons without opening the full server log.\n- Token hygiene: do **not** open/read `build/integration-test-logs/*.log` unless the user explicitly asks for log inspection; rely on the compact console summary by default and ask for permission before digging into full logs.\n- PacketEvents is shaded into the plugin; integration tests do not inject external packet libraries.\n- `relocateIntegrationTestClasses` (ShadowJar) relocates PacketEvents references in test classes only.\n- `integrationTestJar` is a plain `Jar` task that embeds the relocated test classes plus runtime deps, excluding PacketEvents so the test plugin resolves PacketEvents from the main plugin’s shaded copy.\n- `PacketCancellationIntegrationTest` now uses a cancellable `CompletableFuture.await()` so `withTimeout` actually aborts when no packet arrives.\n- `PacketCancellationIntegrationTest` now drives PacketEvents via `PacketEventsImplHelper.handleClientBoundPacket` with a synthetic `User` instead of relying on live channel injection; this avoids timeouts when PacketEvents does not emit send events for fake channels.\n- `PacketCancellationIntegrationTest` sets the Bukkit player on the synthetic PacketEvents event so module listeners can match `PacketSendEvent#getPlayer`.\n- Matrix task: `integrationTest` depends on `integrationTestMatrix` which runs per-version tasks like:\n  - `integrationTest1_19_2`, `integrationTest1_21_11`, `integrationTest1_12`, `integrationTest1_9`\n- Test result handoff:\n  - `OCMTestMain` writes `plugins/OldCombatMechanicsTest/test-results.txt` containing `PASS` or `FAIL`.\n  - `KotestRunner` also writes `plugins/OldCombatMechanicsTest/test-failures.txt` (small failure summary).\n  - Gradle `checkTestResults<version>` fails build if file missing, or content is not `PASS`.\n\n## Version matrix + Java selection\n- Config is in `build.gradle.kts`:\n  - `integrationTestVersions` from property or defaults.\n  - `requiredJavaVersion` selects Java based on version.\n  - Pre-1.13 versions use `integrationTestJavaVersionLegacyPre13` (default 8).\n  - Modern versions use Java 25 if `>=1.20.5` else Java 17.\n- Legacy vanilla jar cache:\n  - `downloadVanilla<version>` task downloads Mojang server jars for <=1.12 and writes `run/<version>/cache/mojang_<version>.jar`.\n\n## Kotlin test runner split (Java 11+ vs Java 8)\n- Kotest 6 (Java 11+) is used for 1.19.2 and 1.21.11.\n  - Kotest runner class: `kernitus.plugin.OldCombatMechanics.KotestRunner`\n  - Project config: `KotestProjectConfig`\n- Java 8 uses a separate runner:\n  - `kernitus.plugin.OldCombatMechanics.LegacyTestRunner`\n  - Currently a **smoke test** only (verifies plugin enabled + `WeaponDamages` loaded).\n  - This is a placeholder and **not a full integration test**.\n\n## Fake player implementation notes\n- Primary fake player implementation is `src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt`.\n- It relies on `xyz.jpenilla:reflection-remapper` to map modern NMS names.\n- On legacy servers (1.12), reflection remapper mappings are unavailable; code falls back to `ReflectionRemapper.noop()`.\n- As-is, `FakePlayer` uses modern NMS class names (`net.minecraft.server.MinecraftServer`, etc.).\n  - This fails on 1.12 which uses versioned NMS (`net.minecraft.server.v1_12_R1.*`).\n  - 1.12 requires a dedicated fake player path or version-aware class mapping.\n\n## Java 8 compatibility work\n- Java 8 compatibility backports already done in main code (records/pattern matching removed).\n- Java 8-incompatible APIs replaced:\n  - `Stream.toList()` -> `collect(Collectors.toList())`\n  - `Set.of`/`List.of` -> `Collections.unmodifiableSet(new HashSet<>(Arrays.asList(...)))` etc.\n- Added missing import in `PotionTypeCompat` for `PotionData`.\n- Build config sets `options.release.set(8)` for Java, Kotlin `jvmTarget = 1.8`.\n\n## Dependency / build updates\n- Kotlin: 2.3.0\n- Kotest: 6.0.7\n- run-paper plugin: 3.0.2\n- Hangar publish plugin: 0.1.4\n- Other deps updated (bstats, netty, BSON, XSeries, authlib, reflection-remapper, adventure).\n- PacketEvents is now used for packet interception (shaded and relocated in the main jar).\n- PacketEvents dependency moved to `2.11.2-SNAPSHOT` (CodeMC snapshots) for 1.21.11 support.\n- JSR-305 added for `javax.annotation.Nullable` (compileOnly).\n\n## Current failing area\n- Paper 1.12 integration tests **do not run real fake player tests yet**.\n- Desired fix: add proper 1.12 fake player implementation (e.g., version-specific NMS path like `v1_12_R1`).\n- Example 1.12 fake player implementation provided by user (NMS, PlayerList manipulation, packet send, etc.).\n- We should integrate a conditional path in `FakePlayer` for 1.12 using versioned NMS classes or an alternate helper.\n\n## Local test commands\n- Run full matrix:\n  - `./gradlew integrationTest`\n- Change matrix:\n  - `./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11,1.12`\n- Set Java toolchain paths (example):\n  - `ORG_GRADLE_JAVA_INSTALLATIONS_PATHS=/path/to/jdk8:/path/to/jdk17:/path/to/jdk25 ./gradlew integrationTest`\n\n## Notes\n- Removed the dead reflection utility `ClassType` and the unused `Reflector#getClass(ClassType, String)` overload; `Reflector#getClass(String)` remains the supported class-resolution helper.\n- `SpigotFunctionChooser` now only falls back for compatibility-style failures (LinkageError family, missing-method/class reflection failures, and explicit compatibility-signalled `UnsupportedOperationException` via `compat`/`compatibility` markers), and rethrows ordinary runtime logic failures instead of silently selecting fallback (for example generic \"incompatible\" wording does not trigger fallback).\n- `AttackCompat` now only treats Bukkit `Player#attack` as success when it yields an observable living-target hit (health/lastDamage/noDamageTicks signal); otherwise it falls back to NMS attack candidates.\n- `AttackCompat` now treats boolean-return NMS attack methods that return `false` as failed attempts and continues trying other candidates, with expanded failure diagnostics including false-result and exception counts.\n- Entity-click dedupe in `ModuleSwordBlocking` now uses a taskless lazy-prune timestamp map (`System.nanoTime`) instead of a one-tick scheduled set clear, with a short dedupe window and periodic/size-triggered expiry pruning.\n- `ModuleSwordBlocking` now handles `PlayerInteractEntityEvent` and `PlayerInteractAtEntityEvent` for main-hand sword blocking, with short time-window dedupe to prevent duplicate side effects when both events fire for the same click.\n- Removed the unused main-source Java tester package (`kernitus.plugin.OldCombatMechanics.tester`), deleted stale commented test-command code from `OCMCommandHandler`, and dropped the now-obsolete Gradle source-set exclusion for that package.\n- Paper 1.12 sometimes fails to download legacy vanilla jar from old Mojang endpoint. The custom `downloadVanilla` task fixes that by using the v2 manifest.\n- 1.21.11 servers log hostname warnings and Unsafe warnings; tests still pass.\n- 1.9 integration tests are currently on hold per user request.\n- Kotest filters (`kotest.filter.specs`, `kotest.filter.tests`) are now passed through Gradle into the run-paper JVM args for integration tests.\n- Reflection should be used only as a fallback (performance cost); prefer direct API/code paths when available.\n- The Hangar publish workflow exports `HANGAR_API_TOKEN` to match the Gradle Hangar publish configuration.\n- `ModuleFishingRodVelocity` uses a single shared per-tick task (1.14+) to adjust hook gravity for all active hooks, instead of one scheduled task per hook.\n- `AttackCooldownTracker#getLastCooldown` is safe to call when the tracker is not registered (returns null) and uses a `HashMap` rather than a `WeakHashMap`.\n- `AttackCooldownTracker` defensively feature-detects `HumanEntity#getAttackCooldown` and avoids scheduling its per-tick sampler when the API exists (modern/backported servers).\n- `ModulePlayerKnockback` uses a `HashMap` + a single shared 1-tick expiry cleaner for pending velocity overrides, instead of `WeakHashMap` + one scheduled task per hit.\n- `ModuleShieldDamageReduction` uses a `HashMap` + a single shared 1-tick expiry cleaner for the “fully blocked” armour-durability suppression, instead of `WeakHashMap` + one scheduled task per hit.\n- `ModuleOldArmourDurability` uses a `HashMap` + a single shared 1-tick expiry cleaner for the “explosion damaged armour” suppression, instead of `WeakHashMap` + one scheduled task per explosion.\n- `ModulePlayerRegen` uses tick-based interval tracking (1.8-like; TPS aware) with a `HashMap` and a single shared tick counter task that runs only while players are tracked.\n- `ModuleDisableEnderpearlCooldown` uses a `HashMap` and lazily drops expired cooldown entries during checks (wall-clock cooldown; no recurring task).\n- `ModuleSwordBlocking` no longer version-gates Paper support; it feature-detects Paper data component APIs and avoids ConcurrentModificationException by iterating legacy tick state over a snapshot.\n- Do not gate behaviour on hard-coded Minecraft version numbers; use feature detection (class/method presence) because some servers backport APIs.\n- Weapon/armour unknown-enchantment warnings only fire for non-`minecraft` namespaces; legacy servers fall back to a known vanilla-enchantment list to avoid warning on built-ins.\n- For NMS access, prefer the project Reflector helpers (`utilities.reflection.Reflector`) over ad-hoc reflection, and avoid hard-coded versioned class names where heuristics (signatures/fields) can locate methods safely.\n- Added integration tests in `OldPotionEffectsIntegrationTest` for strength addend scaling (Strength II and III), a distinct modifier value check, and strength multiplier scaling.\n- Added integration test ensuring vanilla strength addend applies when `old-potion-effects` is disabled.\n- Strength modifier in `OCMEntityDamageByEntityEvent` now stores per-level value (3) and applies level when reconstructing base damage.\n- Added `OldToolDamageMobIntegrationTest` to assert old-tool-damage config affects vindicator iron-axe hits.\n- `KotestRunner` class list updated to include `OldToolDamageMobIntegrationTest`.\n- `ModuleOldToolDamage` now adjusts mob weapon damage by shifting base damage with the configured-vs-vanilla delta for non-player damagers.\n- `ModuleOldToolDamage` documents that mob custom weapons are not detected; the delta is always applied for mobs and may conflict with other plugins.\n- Added `WeaponDurabilityIntegrationTest` covering tool durability vs hit counts during invulnerability and after it expires (FakePlayer attacker vs FakePlayer victim); registered in `KotestRunner`.\n- `WeaponDurabilityIntegrationTest` writes debug summaries to `build/weapon-durability-debug-<version>.txt`.\n- `WeaponDurabilityIntegrationTest` and `OldToolDamageMobIntegrationTest` now resolve debug output paths relative to the repo root (based on the server run directory), avoiding hard-coded home paths.\n- `OldToolDamageMobIntegrationTest` uses a Villager victim and waits for a real Vindicator hit by setting a target and retrying with tick delays (plus a best-effort NMS attackCompat call) before asserting the mob tool-damage delta.\n- Module assignment is strict for configurable modules: every non-internal module must appear in exactly one of `always_enabled_modules`, `disabled_modules`, or a modeset. Internal modules (`modeset-listener`, `attack-cooldown-tracker`, `entity-damage-listener`) are always enabled and must not be listed; reload/enable fails if they are configured.\n- Use British English spelling and phraseology at all times.\n- DO NOT use American English spelling or phraseology under any circumstances.\n- Never hard-code absolute filesystem paths in tests or production code; resolve locations relative to the repo root or server run directory.\n- Added `DisableOffhandIntegrationTest` to assert the disable-offhand modeset-change handler does not clear the offhand when the module is not enabled for the player.\n- `KotestRunner` now includes `DisableOffhandIntegrationTest` in its explicit class list.\n- When adding new integration test specs, add them to the explicit `.withClasses(...)` list in `KotestRunner` because autoscan is disabled.\n- Added `ToolDamageTooltipIntegrationTest` (in `KotestRunner` list) to define behaviour for an opt-in “configured tool damage tooltip” feature (lore line) under `old-tool-damage.tooltip` (`enabled`, `prefix`).\n- `old-tool-damage.tooltip.enabled` is now enabled by default in the bundled config so players can see the configured damage in-game.\n- Modules are enabled/disabled solely via `always_enabled_modules`, `disabled_modules`, and `modesets` (no per-module `enabled:` toggle).\n- `SwordBlockingIntegrationTest` uses synthetic `PlayerInteractEvent` right-clicks; on legacy (offhand-shield) path this cannot reliably assert `isBlocking`/`isHandRaised` (client-driven), so tests treat “blocking applied” as either a shield injection or a raised hand depending on path.\n- Added `PacketCancellationIntegrationTest` to cover PacketEvents sweep-particle and attack-sound cancellation using PacketEvents wrappers/listeners (registered in `KotestRunner`).\n- Added `ModesetRulesIntegrationTest` to cover always-enabled, disabled, and modeset-scoped module rules plus reload failures for invalid assignments.\n- Added `ConfigMigrationIntegrationTest` to cover config upgrade migration into always/disabled module lists and preservation of custom modesets.\n- Added a `weakness should not store negative last damage values` test in `InvulnerabilityDamageIntegrationTest` that forces `old-potion-effects.weakness.modifier = -10`, consumes a weakness potion (amplifier -1), attacks once, and asserts the stored last damage is non-negative; passes on 1.12/1.19.2 after clamping, but 1.21.11 can still fail with `No stored last damage for victim (events=0)` (attack event not recorded).\n- `EntityDamageByEntityListener.checkOverdamage` now clamps the stored `lastDamages` value to a minimum of 0 to avoid negative last-damage entries when weakness (or other modifiers) drives pre-clamp damage below zero.\n- Weakness amplifier clamping changed in 1.20+: attempts to use amplifier `-1` are clamped to `0` (Weakness I). With low-damage weapons this can yield zero vanilla damage, and Paper 1.21 does not fire `EntityDamageByEntityEvent` for zero damage, so tests that rely on an EDBE hit must use a stronger weapon or account for the clamp.\n- `OldPotionEffectsIntegrationTest` now disables the module by moving `old-potion-effects` into `disabled_modules` (and removing it from modesets/always lists), then uses `Config.reload()`; the `withConfig` helper restores module lists/modesets and saves+reloads config to keep state consistent.\n- `FireAspectOverdamageIntegrationTest` includes afterburn-vs-environmental fire-tick checks for both player and zombie victims, with and without Protection IV armour (mirrors issue 707 MRE).\n- Release workflow (`.github/workflows/build-upload-release.yml`) now uploads Bukkit files via `itsmeow/curseforge-upload@v3` against the `minecraft` endpoint, reusing `DBO_UPLOAD_API_TOKEN`, and prefixes `GAME_VERSIONS` as `1:<ver>` so CurseForge selects the Bukkit-compatible type-1 version entries.\n\n## Test harness shortcuts (known non-realistic paths)\n- Several integration tests manually construct and fire Bukkit events rather than triggering real in-world actions:\n  - `GoldenAppleIntegrationTest` (manual `PlayerItemConsumeEvent` and `PrepareItemCraftEvent`)\n  - `OldPotionEffectsIntegrationTest` (manual `PlayerItemConsumeEvent`, `PlayerInteractEvent`, `BlockDispenseEvent`)\n  - `OldArmourDurabilityIntegrationTest` (manual `PlayerItemDamageEvent`, `EntityDamageEvent`)\n  - `PlayerKnockbackIntegrationTest` (manual `EntityDamageByEntityEvent`, `PlayerVelocityEvent`)\n  - `SwordBlockingIntegrationTest` (manual `PlayerInteractEvent`)\n  - `SwordSweepIntegrationTest` (manual `EntityDamageByEntityEvent`)\n- Some tests directly invoke module handlers instead of going through the event bus:\n  - `PlayerKnockbackIntegrationTest` (direct `module.onEntityDamageEntity`)\n  - `SwordSweepIntegrationTest` (direct `module.onEntityDamaged`)\n- `AttributeModifierCompat` synthesises a fallback attack-damage modifier from `NewWeaponDamage` when API attributes are missing.\n- Fake player implementations use simulated login/network plumbing (EmbeddedChannel + manual login/join/quit events), not a real networked client.\n- FakePlayer uses a plain `EmbeddedChannel` (not an anonymous subclass) so PacketEvents treats it as fake and does not disallow login; dummy `decoder`/`encoder` handlers are still added to the pipeline.\n- FakePlayer now schedules a manual NMS tick for non-legacy servers (prefers `doTick`, then `tick`, falls back to `baseTick`) to drive vanilla ticking like fire and passive effects.\n- FakePlayer tick shim invokes `baseTick` whenever `remainingFireTicks > 0` (burning) to ensure fire tick damage events still occur on Paper 1.21+ (which can short-circuit `doTick`/`tick` for fake players). When the fake player is in water, it prefers `doTick`/`tick` so the server can properly clear fire ticks (extinguish) before any fire damage is applied.\n- FakePlayer now prefers `PlayerList.placeNewPlayer` over the legacy `load`/manual list insertion path to better mirror vanilla login initialisation (helps player fire-tick damage on modern servers).\n- FakePlayer does not emulate fire-tick damage; fire ticks should be driven by the NMS tick path.\n- FakePlayer now forces a world add when the Bukkit world does not report the fake player entity after `placeNewPlayer` to keep PvP interactions reliable.\n- FakePlayer now clears invulnerability/instabuild abilities after spawn (plus a legacy fallback) to improve PvP interactions between fake players.\n- EntityDamageByEntityListener now logs extra debug about lastDamage restoration for non-entity damage, and documents the vanilla 1.12 damage flow in checkOverdamage.\n- EntityDamageByEntityListener no longer overwrites the stored last-damage baseline when cancelling “fake overdamage” (e.g. cancelled fire tick during invulnerability), preventing subsequent hits from incorrectly bypassing immunity.\n- Stored last-damage baselines now use a single lightweight expiry sweeper (tick-based TTL) instead of scheduling one Bukkit task per damage event; this keeps the hot path allocation-free. Expiry is monotonic (only extended, never shortened) and has a small minimum TTL to tolerate `maximumNoDamageTicks = 0`.\n- `WeaponDurabilityIntegrationTest` now uses a Zombie victim (fake-player attacker) and prefers the Bukkit `Player#attack` API before falling back to reflective NMS attack resolution, to make hit delivery reliable on modern servers.\n- `ModuleSwordSweepParticles` and `ModuleAttackSounds` now use PacketEvents listeners/wrappers instead of ProtocolLib.\n- `PacketCancellationIntegrationTest` now builds a `PacketSendEvent` directly (reflection) from the transformed buffer, sets the packet id/type explicitly, and dispatches it via the PacketEvents `eventManager` so module listeners can cancel reliably.\n- README now includes a licence note: source remains MPL‑2.0, but pre-built jars bundling PacketEvents are distributed under GPLv3; builds without PacketEvents can remain MPL‑2.0.\n- Legacy fake player (1.9) now uses a plain `EmbeddedChannel` with dummy `decoder`/`encoder` handlers, mirroring the modern fake player setup so PacketEvents treats it as fake.\n- `ModuleChorusFruit` now reimplements the chorus teleport search (16 attempts, world-border aware, passable feet/head, solid ground) for custom teleport distances; falls back to vanilla target if no safe spot found.\n- Added `ChorusFruitIntegrationTest` (in KotestRunner list) to assert custom chorus teleport distance lands on a safe block within the configured radius.\n- Chorus fruit safety test now handles legacy 1.12 by using solid/non-solid checks when `Block#isPassable` is absent; passes on 1.12, 1.19.2, and 1.21.11.\n- `ModuleOldToolDamage` now supports configurable TRIDENT (melee), TRIDENT_THROWN, and MACE damage; mace preserves its vanilla fall bonus while overriding base damage. New defaults added to `old-tool-damage.damages`.\n- Added `ModuleAttackRange` (Paper 1.21.11+ only) to apply a configurable attack_range data component (default 1.8-like: 0–3 range, creative 0–4, margin 0.1, mob factor 1.0); auto-disables on Spigot/older versions. `attack-range` module listed in `disabled_modules` by default.\n- `ModuleSwordBlocking` now only strips the Paper `CONSUMABLE` component from sword items, preventing food and other consumables from inheriting a `!minecraft:consumable` patch when inventory events fire on 1.20.5+.\n- Added `DisableOffhandReflectionIntegrationTest` (in `KotestRunner` list) to ensure reflective access to `InventoryView#getBottomInventory`/`getTopInventory` works on non-public CraftBukkit view implementations.\n- `Reflector.getMethod` overloads now include declared methods and call `setAccessible(true)` to avoid `IllegalAccessException` when CraftBukkit uses non-public view classes (e.g. `CraftContainer$1` on 1.20.1).\n- Added `AttackRangeIntegrationTest` (1.21.11+) to assert vanilla hits at ~3.6 blocks and 1.8-style attack_range reduces reach so the same hit misses; registered in `KotestRunner`.\n- Removed the cancelled S3-only `AttackRangeIntegrationTest` case `swap hand keeps attack-range off offhand sword (Paper 1.21.11+)` so ongoing S6 validation is not blocked by a parked slice artefact.\n- InvulnerabilityDamageIntegrationTest adds a case asserting environmental damage above the baseline applies during invulnerability (manual EntityDamageEvent).\n- `gradle.properties` gameVersions list now includes 1.21.11 down to 1.21.1 (plus 1.21) ahead of existing entries.\n- GitHub release asset now keeps a stable filename `OldCombatMechanics.jar` (no version suffix); the CurseForge upload uses the same path.\n- Expanded `SwordBlockingIntegrationTest` to cover right-click blocking, non-sword handling, offhand restoration (hotbar change and drop cancel), permission gating, and preserving an existing real shield.\n- Added `PaperSwordBlockingDamageReductionIntegrationTest` (in KotestRunner list) to regression-test that Paper sword blocking is recognised server-side (via `ModuleSwordBlocking.isPaperSwordBlocking`) and produces a non-zero 1.8-style reduction from `applyPaperBlockingReduction`. This uses a synthetic damage event to avoid flakiness from mob AI / PvP settings.\n- Paper-only sword blocking uses a runtime-gated helper (`kernitus.plugin.OldCombatMechanics.paper.PaperSwordBlocking`) that applies consumable + blocking components via cached reflection (lookup once, MethodHandle invoke thereafter). 1.8-style reduction is applied via `EntityDamageByEntityListener` using the `BLOCKING` damage modifier, while legacy/non-Paper keeps the shield swap path.\n- Fixed legacy (1.9.x) sweep detection flakiness by tracking priming per-attacker UUID (not `Location`, which is unstable due to yaw/pitch) and clearing the set on module reload; stabilises `SwordSweepIntegrationTest` on 1.9.4.\n- `ModuleSwordSweep` now uses a one-shot next-tick clear (single scheduled task guarded by `pendingClearTask`) instead of a repeating every-tick task, to avoid doing any work on ticks where no sword hits occurred.\n- `ModuleSwordBlocking` legacy (offhand shield) path no longer schedules per-player repeating tasks; it now uses a single shared tick runner with per-player tick deadlines (10-tick warmup + 2-tick poll + restoreDelay), which reduces task churn under heavy right-click spam.\n- `ModuleLoader` now clears the static module list on initialise to prevent duplicate registrations after hot reloads.\n- Added `ConsumableComponentIntegrationTest` coverage to assert the Paper sword-blocking consumable cleaner leaves swords untouched when the module is disabled (modeset/world) and does not mutate swords when no component change is required.\n- `ConsumableComponentIntegrationTest` now seeds and asserts the CONSUMABLE component via NMS reflection (not Paper API) and uses standalone CraftItemStacks for cursor/current items to avoid classloader mismatches and fake-player cursor side effects.\n- `ModuleSwordBlocking#onModesetChange` now strips the Paper CONSUMABLE component from the player’s main hand/offhand and stored swords when sword-blocking is disabled for that player, preventing component “taint” lingering after mode changes.\n- `ModuleSwordBlocking#reload` now strips Paper sword-blocking consumable components from online players when the module is disabled globally (disabled_modules) to avoid lingering taint after config reloads.\n- `ModuleSwordBlocking` now gates the Paper consumable animation by PacketEvents client version (>=1.20.5); older clients fall back to the offhand shield and unknown versions default to the animation path.\n- `config.yml` now documents that Paper 1.20.5+ uses the consumable-based sword-blocking animation, with older/Paperless servers falling back to an offhand shield.\n- `config.yml` now notes that ViaVersion clients older than 1.20.5 also fall back to the shield behaviour.\n- Added `ConsumableComponentIntegrationTest` coverage for disabling sword-blocking via `disabled_modules` and asserting the consumable component is cleared after config reload.\n- Extended `ConsumableComponentIntegrationTest` to cover disabled-module right-click suppression, reload toggling, stored-inventory cleanup, offhand stability, and modeset-change behaviour after a disabled reload.\n- Added `ConsumableComponentIntegrationTest` coverage for forcing an older client version and asserting sword-blocking falls back to an offhand shield without applying consumable components.\n- `ConsumableComponentIntegrationTest` now uses PacketEvents reflection to seed a User/client version for fake players when PacketEvents has not registered one yet.\n- `ModuleSwordBlocking#restore` no longer skips offhand restoration just because Paper support is present; older clients using the shield fallback now restore their original offhand item correctly.\n- Added `ConsumableComponentIntegrationTest` coverage asserting the older-client shield fallback restores the offhand item on hotbar change.\n- Added `ConsumableComponentIntegrationTest` coverage for inventory-glitch regressions: unknown PacketEvents client versions should use shield fallback, middle-clicking custom GUIs should not mutate held sword components, and custom-GUI drag events should not rewrite top-inventory swords.\n- Those inventory-glitch regressions now pass on 1.21.11 after the unknown-client fallback + click/drag scope fixes.\n- `ModuleSwordBlocking#supportsPaperAnimation` now treats unknown PacketEvents client versions as shield-fallback only when a PacketEvents `User` exists; if no user is registered yet (early login/synthetic test player), it keeps animation support to avoid regressing normal modern-client behaviour.\n- `ModuleSwordBlocking.ConsumableCleaner#onInventoryClickPost` now re-applies the main-hand consumable component only for click paths that can actually affect the selected hotbar slot (selected NUMBER_KEY swaps or direct selected-slot player-inventory clicks), and ignores middle-clicks.\n- `ModuleSwordBlocking.ConsumableCleaner#onInventoryDrag` now ignores top-inventory raw slots and skips main-hand re-application when the drag only touched top inventory slots, preventing custom-GUI slot rewrites.\n- Added `ConsumableComponentIntegrationTest` coverage for legacy fallback shield-guard scope: custom GUI shield-icon clicks and unrelated shield drops should not be cancelled while fallback is active; temporary offhand shield swap blocking should remain enforced.\n- `ModuleSwordBlocking#onInventoryClick` legacy shield-guard scope now only cancels direct offhand temporary-shield interactions (player inventory slot 40), avoiding cancellation of unrelated custom-GUI shield clicks.\n- `ModuleSwordBlocking#onInventoryClick` also blocks `ClickType.SWAP_OFFHAND` while temporary legacy shield state is active, preventing offhand shield extraction via inventory swap-clicks.\n- `ModuleSwordBlocking#onItemDrop` no longer cancels shield drops while legacy fallback state is active; it now force-restores the temporary shield state immediately to avoid trapping unrelated shield drops.\n- `ModuleSwordBlocking#isPlayerBlocking` now requires an actual offhand shield before treating `isBlocking`/`isHandRaised` as legacy shield-blocking, preventing stale hand-use state from suppressing fallback shield injection.\n- `ModuleSwordBlocking#supportsPaperAnimation` now falls back to `User#getClientVersion` when `PlayerManager#getClientVersion` is null, improving old-client fallback stability in synthetic/integration scenarios.\n- `ModuleSwordBlocking#supportsPaperAnimation` now fails safe to legacy shield fallback when PacketEvents client-version resolution is unavailable (resolver initialisation missing, resolver methods/objects null, or reflection errors), while preserving existing behaviour for normal early-login/synthetic-player cases.\n- `ModuleSwordBlocking#onPlayerSwapHandItems` now treats stale legacy stored-shield state as non-authoritative for Paper-animation players: it clears stale legacy entries instead of cancelling swaps, so synthetic swap listeners still run.\n- `ModuleSwordBlocking#onPlayerSwapHandItems` now restores any stale legacy stored offhand item (via `restore(..., true)`) before clearing legacy state on the Paper-animation path, so stale entries are not silently discarded.\n- `ConsumableCleaner#onSwap` now snapshots the held hotbar slot and only reapplies the Paper consumable component when the slot is unchanged on the deferred tick; offhand stripping remains deferred as before.\n- The new legacy-scope regressions now pass on 1.19.2 and 1.21.11.\n- `ModuleSwordBlocking.ConsumableCleaner#onInventoryClickPost` now snapshots click context (held slot and open inventory top/bottom) and skips next-tick reapply when that context is stale, preventing deferred reapply from tainting a newly selected main-hand sword.\n- Added `ConsumableComponentIntegrationTest` coverage for stale `PlayerSwapHandItemsEvent` deferred reapply: if held slot changes before next tick, the newly selected main-hand sword must not gain a consumable component, while swapped-offhand sword cleanup still occurs.\n- Added `ConsumableComponentIntegrationTest` regression coverage for stale `PlayerSwapHandItemsEvent` deferred reapply when the open inventory view changes before next tick: the new main-hand context must not be tainted, while swapped-offhand consumable cleanup must still run.\n- `ModuleSwordBlocking.ConsumableCleaner#onSwap` now snapshots open-inventory top/bottom identity at swap time and requires a view match only for deferred main-hand reapply; deferred offhand consumable cleanup still runs even when the view changed.\n- `ModuleSwordBlocking.ConsumableCleaner` click/drag handlers now follow a minimal-mutation policy (no proactive consumable strip/apply in `InventoryClickEvent` or `InventoryDragEvent` paths); cleanup is handled opportunistically via lifecycle/transition handlers (`onModesetChange`/`reload`/world-change, held-slot, and swap paths) using a shared per-player consumable sweep helper.\n- `ModuleSwordBlocking#onModesetChange` now force-restores stale legacy fallback shield state (`restore(..., true)`) when sword-blocking becomes disabled for a player, before sweeping Paper consumable components.\n- `ModuleSwordBlocking.ConsumableCleaner#onWorldChange` now strips consumable components from main hand, offhand, and stored swords without re-applying, so world changes clear stale consumable state consistently.\n- `ModuleSwordBlocking.ConsumableCleaner#onQuit` now uses the same strip-only full sweep as world-change cleanup (main hand, offhand, and stored swords), ensuring logout clears stale consumable state from storage as well.\n- `ModuleSwordBlocking` now handles `PlayerJoinEvent` with a force legacy restore (`restore(..., true)`) followed by strip-only consumable cleanup (main hand, offhand, and stored swords), so join clears stale state without re-applying consumable components.\n- `ModuleSwordBlocking` inner listener `ConsumableCleaner` was renamed to `ConsumableLifecycleHandler` (registration updated; behaviour unchanged).\n- Added `sword-blocking.paper-animation` config (default `true`) to hard-disable the Paper consumable animation path; when false, sword-blocking always uses the legacy shield fallback and reload/lifecycle cleanup strips stale consumable state without re-applying it.\n- Legacy sword-blocking now marks injected temporary offhand shields when marker APIs are available so death handling can identify the temporary drop path reliably.\n- `ModuleSwordBlocking#onPlayerDeath` now pops stored offhand exactly once, clears legacy state deterministically, respects `keepInventory` without rewriting drops, rewrites at most one temporary shield drop, and adds the stored offhand drop when no temporary shield drop is found.\n- Legacy shield-drop reconciliation now uses a strict safety-first fallback when marker APIs are unavailable: it does not guess temporary shields from plain shield shape, avoiding accidental replacement of legitimate shields (stored offhand is appended instead).\n- For synthetic/manual death events where drop metadata may not preserve the temporary-shield marker, `ModuleSwordBlocking#onPlayerDeath` allows a single shield-drop rewrite only when the player offhand was verified as marker-tagged at death time.\n- `ModuleAttackCooldown` now supports `disable-attack-cooldown.held-item-attack-speeds.<MATERIAL>` overrides with XMaterial/Material matching, falls back to `generic-attack-speed`, reconciles on join/world/modeset/hotbar/swap, restores vanilla base ATTACK_SPEED 4.0 when disabled, and no longer calls `player.saveData()` on attack-speed updates. The bundled config defaults keep most items on the no-cooldown fallback, with `TRIDENT`, `MACE`, and spear variants listed as the built-in slower exceptions; keys for newer-version materials are ignored safely on older servers.\n- `ModuleAttackCooldown` hotbar/swap handling is immediate best-effort only: at `EventPriority.HIGHEST`, it re-reads the live inventory only when the event is already cancelled, otherwise it trusts the event payload and applies straight away; later plugin mutations may win until the next ordinary reconcile trigger.\n- `.github/CONTRIBUTING.md` now states that contributions should include automated coverage within the existing test framework where practical, and that new classes should be written in Kotlin by default.\n\n## Fire aspect / fire tick test notes\n- `FireAspectOverdamageIntegrationTest` now uses a Zombie victim for real fire tick sampling, with max health boosted (via MAX_HEALTH attribute) to survive rapid clicking.\n- The first two tests fire a synthetic `EntityDamageEvent` with `FIRE_TICK` to control timing and make the baseline check deterministic.\n- Paper 1.12 applies attack-cooldown scaling before the Bukkit damage event fires; fake players can start with a low initial cooldown, producing a weak first hit and allowing a stronger second hit as legitimate “overdamage”. `FireAspectOverdamageIntegrationTest` now waits a few ticks before the first attack to make the first hit stable on 1.12.\n- Added extra fire edge-case coverage in `FireAspectOverdamageIntegrationTest`: fire resistance + fire-immune victim rapid-click parity, water extinguish behaviour (no fire tick damage while submerged), fire protection vs protection comparisons (uses a Zombie victim for consistency), and alternating attackers during invulnerability.\n- Legacy (1.12) fake player behaviour differs for player-specific fire tick sampling, so the player afterburn-vs-environmental comparisons are no-ops on legacy; the Zombie variants still run across all versions.\n\n## TDAID reminders (this repo)\n- Plan → Red → Green → Refactor → Validate.\n- Red phase: only touch tests. Do not modify production code.\n- Green phase: only touch production code. Do not modify tests.\n- Refactor phase: cleanups only; keep behavior unchanged and tests green.\n- Validate phase: rerun tests and do a human sanity check before declaring done.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [2.4.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.3.0...v2.4.0) (2026-03-08)\n\n\n### Features\n\n* togglable paper sword blocking ([5d3887d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5d3887d5b18849ec150cabbd459e66104708dda3))\n\n\n### Bug Fixes\n\n* don't overwrite swords on every click [#843](https://github.com/kernitus/BukkitOldCombatMechanics/issues/843) ([4323853](https://github.com/kernitus/BukkitOldCombatMechanics/commit/432385320c418c0e46df6869e56af051629bfdab))\n* **inventory:** harden stale deferred item mutation paths ([a92664d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a92664d293af0dd5fd445ca7db47e078386affd7))\n* **reflection:** tighten chooser compatibility fallback ([f383340](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f3833406e4b471a3d89f5e1c5ed8ada9d1d3e1ae))\n* strip sword consumable component ([4624ac0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4624ac0c422d7f05835cd511b701026769917292))\n* **sword-blocking:** clear sword consumable components on reload when disabled ([b887a28](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b887a28a95b07efa5ec641660aee590207a0d50d)), closes [#845](https://github.com/kernitus/BukkitOldCombatMechanics/issues/845)\n* **sword-blocking:** fall back to shield for pre-1.20.5 clients ([388bee5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/388bee573169a45a27c41558a946e493880677e7)), closes [#842](https://github.com/kernitus/BukkitOldCombatMechanics/issues/842)\n* **sword-blocking:** harden inventory fail-safes to prevent ghosting ([8889bfe](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8889bfeb467c9f64f4d35a2d35e8456f179c35d1))\n* **sword-blocking:** harden legacy death shield drop reconciliation ([2bb730e](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2bb730e5c223da5c1cd73c91a093c86d3dedefbb))\n* **sword-blocking:** prevent GUI click/drag item rewrites & fallback unknown clients to shield ([cfc596c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfc596c3d674fd99a9c07b4c7c07c354d58fc755))\n* **sword-blocking:** prevent legacy fallback from cancelling unrelated shield interactions ([727fa97](https://github.com/kernitus/BukkitOldCombatMechanics/commit/727fa97b4e904ac06514170aae27ba1bc91dbb8b))\n* **sword-blocking:** restore offhand item for pre-1.20.5 fallback clients ([68073e8](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68073e876dffa853329c414d156086fe1c76b3fd))\n* **sword-blocking:** sweep stale consumable state on join/quit/world ([96d6981](https://github.com/kernitus/BukkitOldCombatMechanics/commit/96d698145e9d8f1543b272adf87d5026076de927))\n\n## [2.3.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.2.0...v2.3.0) (2026-01-24)\n\n\n### Features\n\n* 1.8 hitbox ([5cbd93d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5cbd93d89dde101dd7e750d4ee13e733b6dfd4a2)), closes [#69](https://github.com/kernitus/BukkitOldCombatMechanics/issues/69)\n* always & disabled modules lists ([5f7bf12](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5f7bf127412e2675294d27703ac6562a4c8e0c9d))\n* always & disabled modules lists ([18dd6e1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/18dd6e17aeb5e391fbb5704c0726d05e0b676971))\n* copper tools support [#822](https://github.com/kernitus/BukkitOldCombatMechanics/issues/822) [#823](https://github.com/kernitus/BukkitOldCombatMechanics/issues/823) ([47ffa96](https://github.com/kernitus/BukkitOldCombatMechanics/commit/47ffa963ecc8619f064b98054d35df81f89a682d))\n* custom trident & mace damage ([be70c5b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/be70c5bb7756699fe4e3cb5bced450df3308f195)), closes [#757](https://github.com/kernitus/BukkitOldCombatMechanics/issues/757)\n* item damage lore ([0ca855a](https://github.com/kernitus/BukkitOldCombatMechanics/commit/0ca855a21634c933ab0626770a74f756e57849fe)), closes [#775](https://github.com/kernitus/BukkitOldCombatMechanics/issues/775)\n* kotlin integration tests ([c63c940](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c63c940fcc292d4db3a27185a613a01e7c5c04f0))\n* switch from protocollib to packetevents ([44afce1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/44afce1a0f01a0ad54b39662e3597a8e371c5454)), closes [#790](https://github.com/kernitus/BukkitOldCombatMechanics/issues/790)\n* sword blocking animation [#769](https://github.com/kernitus/BukkitOldCombatMechanics/issues/769) ([8596c9d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8596c9da58dc2167d05ac878d4a7809014ff02d8))\n* warn on unknown effects, enchants, etc ([fa828d3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fa828d3469ca18b51ffd0871bd8e510f0c831d1c))\n\n\n### Bug Fixes\n\n* 'disable-offhand' module working even if disabled ([ecca0b5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ecca0b56d6f8c1d9b2e96d190ae2098dfeb10fc4))\n* `disable-offhand` handling on modeset change ([54aaf0c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/54aaf0c1a0a812c89c39ae0c49fe6300760a8ecc))\n* apply old tool damage to all mobs ([91b121f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/91b121ff009971586c877778e6a75309088ba667)), closes [#735](https://github.com/kernitus/BukkitOldCombatMechanics/issues/735)\n* chorus fruit tp into blocks ([bba1ecb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/bba1ecb62ec9faf1526189f308798d3b25f43cf9)), closes [#748](https://github.com/kernitus/BukkitOldCombatMechanics/issues/748)\n* clear modules list on reload ([ebdfd10](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ebdfd101ae1393dbf97c431726b09ea797cc267d))\n* double strength effect when old-potion-effects disabled ([4cecb64](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4cecb649c8e03c0c2fd593b907da778e3f7dc453)), closes [#781](https://github.com/kernitus/BukkitOldCombatMechanics/issues/781)\n* fire damage overwriting lastDamage ([9af8a0f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9af8a0fa796513ab88588612f5551c5bf582db32)), closes [#707](https://github.com/kernitus/BukkitOldCombatMechanics/issues/707)\n* legacy (pre-1.11) sweep detection ([2825b35](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2825b35b4c6b0b879389da170e6d85b9441d9799))\n* negative last damage ([1321717](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1321717288ec9624065d86b00aa441f9a1404b52)), closes [#765](https://github.com/kernitus/BukkitOldCombatMechanics/issues/765)\n* only strip consumable on swords ([e01faea](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e01faea183d8387371dbd62e89fb847deb2fc38e)), closes [#841](https://github.com/kernitus/BukkitOldCombatMechanics/issues/841)\n* reflection error on weapon enchant ([10ff4ce](https://github.com/kernitus/BukkitOldCombatMechanics/commit/10ff4ceead5147b87c13ffea1401ef716596b80c)), closes [#840](https://github.com/kernitus/BukkitOldCombatMechanics/issues/840)\n* skip unknown sound packets ([cfa1e58](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfa1e58b5b5e481e1427458d22a98e487b32a621))\n* unknown particles. ([1408720](https://github.com/kernitus/BukkitOldCombatMechanics/commit/140872008929ecc49918bdf0cc8e40d226957054)), closes [#825](https://github.com/kernitus/BukkitOldCombatMechanics/issues/825)\n* weakness calc on &gt;=1.20 ([f502adb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f502adbbece03875e491bd1f39e4a45d1f498802))\n* weakness calculations for amplifier &gt; 1 ([d866015](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d86601504eb6d16481e18946cf07e7432039cd92))\n\n\n### Performance\n\n* **attack-cooldown-tracker:** gate sampler by API presence and use HashMap for stable caching ([b605501](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b605501b40d350457d5227ce24c9c9a9522ed1f9))\n* **disable-enderpearl-cooldown:** use HashMap and lazily drop expired cooldown entries ([83ff726](https://github.com/kernitus/BukkitOldCombatMechanics/commit/83ff726c5a9a2c2e6cc16571ce6e2d37123341ab))\n* **fishing-rod-velocity:** replace per-hook gravity tasks with single shared tick runner ([5856bed](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5856bed51830c51b0b6c205cb010fcb8dd9be0ac))\n* **old-armour-durability:** replace per-explosion suppression task with shared 1-tick expiry cleaner ([c7932d6](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c7932d6f0b32966ad38cdb81f8c83b14ca438478))\n* **old-player-regen:** switch to tick-based interval tracking with shared counter task ([4f8df3c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4f8df3c5d0781240a46a4ed7e1ce10043aafb8d1))\n* **player-knockback:** replace per-hit cleanup tasks with shared 1-tick expiry cleaner ([9667ef5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9667ef539e033ede2b97fadf309cd805df6900c5))\n* **shield-damage-reduction:** replace per-hit fully-blocked cleanup tasks with shared 1-tick expiry cleaner ([a18b1ef](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a18b1efec039c35b12f2c7c53dcc2be875545b29))\n* **sword-block:** reduce amount of recurring tasks ([a08b5b4](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a08b5b47bc5c1645db885ffca794e3ac7d9592ba))\n* use one task to clear EDBEE map ([11ec51b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/11ec51bd609339b8e41d74ef2d8697c3083a7d5f))\n\n## [2.2.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.1.0...v2.2.0) (2025-10-14)\n\n\n### Features\n\n* add fallback sound reflection logic ([1592dc2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1592dc249870ad3113b924cdebd41cbc36b68ad5))\n\n\n### Bug Fixes\n\n* ocm mode permissions ([e2f0369](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e2f0369f294e250e8cfc474bbd1121498ecf09fe)), closes [#818](https://github.com/kernitus/BukkitOldCombatMechanics/issues/818)\n* update checker versioning logic ([8d88a49](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8d88a49d0fa1e8c7dfa7c48cf66c8399c766d6e0))\n* use reflection for inventory view ([462e536](https://github.com/kernitus/BukkitOldCombatMechanics/commit/462e536628f3df544c9cf0fe42705eff46b7d4f6)), closes [#812](https://github.com/kernitus/BukkitOldCombatMechanics/issues/812)\n\n\n### Documentation\n\n* update issue templates ([5927729](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5927729ea5fcca44a6218b10f40cec3f8ce3d4a3))\n* update issue templates ([09fd3c5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/09fd3c556ad02d25caa875e7dfe5854888a920cf))\n* use yaml issue forms ([fc51924](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fc51924504e3718b9829f4a7deca7a8701000f9f))\n\n## [2.1.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.0.4...v2.1.0) (2025-08-21)\n\n\n### Features\n\n* compat with 1.21.8 enums ([68c51ab](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68c51ab8803da56f477660af247c37e5171bc581))\n* make config upgrader remove deprecated keys ([e728374](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e72837462fb8c512c9971c1e7d9376c82f37e741))\n* remove deprecated modules ([da3d5f0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/da3d5f0b28990d8f654b8557e68f54f41e8b5a60))\n\n\n### Bug Fixes\n\n* check fishing knockback module enabled when reeling in ([31e3877](https://github.com/kernitus/BukkitOldCombatMechanics/commit/31e3877330cdd820173a5d89e021919b153c6988)), closes [#803](https://github.com/kernitus/BukkitOldCombatMechanics/issues/803)\n* disable attack sounds not working in &gt;1.21 ([b355322](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b355322f9b3f5e1c2b1e889684d6242f08ceee92)), closes [#794](https://github.com/kernitus/BukkitOldCombatMechanics/issues/794)\n* ExceptionInInitialiserError in enchantment compat ([b2379cc](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b2379cc17e309b035c2acb396392723f15ed3ee2)), closes [#782](https://github.com/kernitus/BukkitOldCombatMechanics/issues/782)\n* improve sound packet compatibility ([4ef28fb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4ef28fb7bd63631fa4b6f0366d23dd1e159aa115)), closes [#780](https://github.com/kernitus/BukkitOldCombatMechanics/issues/780)\n* null pointer in potion compat ([b30e27d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b30e27ddb32d210371152feee8d337f27ea8f495)), closes [#791](https://github.com/kernitus/BukkitOldCombatMechanics/issues/791)\n* unmentioned worlds modesets not allowed [#792](https://github.com/kernitus/BukkitOldCombatMechanics/issues/792) ([95c9446](https://github.com/kernitus/BukkitOldCombatMechanics/commit/95c9446edbd0fe56bce0864798cf8d1c70865f8b))\n\n\n### Refactoring\n\n* improve fishing knockback cross-version compat ([d119c9f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d119c9f35e1a89be8fb8d03573041cfc2ae2d418))\n\n## [2.0.4](https://github.com/kernitus/BukkitOldCombatMechanics/compare/2.0.3...v2.0.4) (2024-10-28)\n\n\n### Bug Fixes\n\n* avoid ghost items after sword block restore ([822fb1f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/822fb1fa147fc49266cb9f0668869959e341982e)), closes [#749](https://github.com/kernitus/BukkitOldCombatMechanics/issues/749)\n* don't prevent moving shield to chests in disable offhand module ([b299df2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b299df2d21ace1c7e88b1ee8fafb297e2a9347e8))\n* error when right clicking air while holding block in &lt;1.13 ([cbc0c4b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cbc0c4bc8bf0afd56005699ce70f86ec9b637646)), closes [#754](https://github.com/kernitus/BukkitOldCombatMechanics/issues/754)\n* listen to dynamically loaded worlds for modesets ([f5b59d7](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f5b59d7537d410fac35fbb4e0181a61a485ae1a5)), closes [#747](https://github.com/kernitus/BukkitOldCombatMechanics/issues/747)\n* resolve elytras always unequipped by removing out-of-scope module ([07106e6](https://github.com/kernitus/BukkitOldCombatMechanics/commit/07106e61a220ec4137a3de200a393cf6aaa50be7)), closes [#725](https://github.com/kernitus/BukkitOldCombatMechanics/issues/725)\n* fix sword blocking shield ending up in inventory on world change ([8aa3fa3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8aa3fa33081c1e1b1a48baa484fd6946b275362b)), closes [#753](https://github.com/kernitus/BukkitOldCombatMechanics/issues/753)\n"
  },
  {
    "path": "LICENCE",
    "content": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n    means each individual or legal entity that creates, contributes to\n    the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n    means the combination of the Contributions of others (if any) used\n    by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n    means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n    means Source Code Form to which the initial Contributor has attached\n    the notice in Exhibit A, the Executable Form of such Source Code\n    Form, and Modifications of such Source Code Form, in each case\n    including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n    means\n\n    (a) that the initial Contributor has attached the notice described\n        in Exhibit B to the Covered Software; or\n\n    (b) that the Covered Software was made available under the terms of\n        version 1.1 or earlier of the License, but not also under the\n        terms of a Secondary License.\n\n1.6. \"Executable Form\"\n    means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n    means a work that combines Covered Software with other material, in\n    a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n    means this document.\n\n1.9. \"Licensable\"\n    means having the right to grant, to the maximum extent possible,\n    whether at the time of the initial grant or subsequently, any and\n    all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n    means any of the following:\n\n    (a) any file in Source Code Form that results from an addition to,\n        deletion from, or modification of the contents of Covered\n        Software; or\n\n    (b) any new file in Source Code Form that contains any Covered\n        Software.\n\n1.11. \"Patent Claims\" of a Contributor\n    means any patent claim(s), including without limitation, method,\n    process, and apparatus claims, in any patent Licensable by such\n    Contributor that would be infringed, but for the grant of the\n    License, by the making, using, selling, offering for sale, having\n    made, import, or transfer of either its Contributions or its\n    Contributor Version.\n\n1.12. \"Secondary License\"\n    means either the GNU General Public License, Version 2.0, the GNU\n    Lesser General Public License, Version 2.1, the GNU Affero General\n    Public License, Version 3.0, or any later versions of those\n    licenses.\n\n1.13. \"Source Code Form\"\n    means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n    means an individual or a legal entity exercising rights under this\n    License. For legal entities, \"You\" includes any entity that\n    controls, is controlled by, or is under common control with You. For\n    purposes of this definition, \"control\" means (a) the power, direct\n    or indirect, to cause the direction or management of such entity,\n    whether by contract or otherwise, or (b) ownership of more than\n    fifty percent (50%) of the outstanding shares or beneficial\n    ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n    Licensable by such Contributor to use, reproduce, make available,\n    modify, display, perform, distribute, and otherwise exploit its\n    Contributions, either on an unmodified basis, with Modifications, or\n    as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n    for sale, have made, import, and otherwise transfer either its\n    Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n    or\n\n(b) for infringements caused by: (i) Your and any other third party's\n    modifications of Covered Software, or (ii) the combination of its\n    Contributions with other software (except as part of its Contributor\n    Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n    its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n    Form, as described in Section 3.1, and You must inform recipients of\n    the Executable Form how they can obtain a copy of such Source Code\n    Form by reasonable means in a timely manner, at a charge no more\n    than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n    License, or sublicense it under different terms, provided that the\n    license for the Executable Form does not attempt to limit or alter\n    the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n*                                                                      *\n*  6. Disclaimer of Warranty                                           *\n*  -------------------------                                           *\n*                                                                      *\n*  Covered Software is provided under this License on an \"as is\"       *\n*  basis, without warranty of any kind, either expressed, implied, or  *\n*  statutory, including, without limitation, warranties that the       *\n*  Covered Software is free of defects, merchantable, fit for a        *\n*  particular purpose or non-infringing. The entire risk as to the     *\n*  quality and performance of the Covered Software is with You.        *\n*  Should any Covered Software prove defective in any respect, You     *\n*  (not any Contributor) assume the cost of any necessary servicing,   *\n*  repair, or correction. This disclaimer of warranty constitutes an   *\n*  essential part of this License. No use of any Covered Software is   *\n*  authorized under this License except under this disclaimer.         *\n*                                                                      *\n************************************************************************\n\n************************************************************************\n*                                                                      *\n*  7. Limitation of Liability                                          *\n*  --------------------------                                          *\n*                                                                      *\n*  Under no circumstances and under no legal theory, whether tort      *\n*  (including negligence), contract, or otherwise, shall any           *\n*  Contributor, or anyone who distributes Covered Software as          *\n*  permitted above, be liable to You for any direct, indirect,         *\n*  special, incidental, or consequential damages of any character      *\n*  including, without limitation, damages for lost profits, loss of    *\n*  goodwill, work stoppage, computer failure or malfunction, or any    *\n*  and all other commercial damages or losses, even if such party      *\n*  shall have been informed of the possibility of such damages. This   *\n*  limitation of liability shall not apply to liability for death or   *\n*  personal injury resulting from such party's negligence to the       *\n*  extent applicable law prohibits such limitation. Some               *\n*  jurisdictions do not allow the exclusion or limitation of           *\n*  incidental or consequential damages, so this exclusion and          *\n*  limitation may not apply to You.                                    *\n*                                                                      *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n  This Source Code Form is subject to the terms of the Mozilla Public\n  License, v. 2.0. If a copy of the MPL was not distributed with this\n  file, You can obtain one at http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n  This Source Code Form is \"Incompatible With Secondary Licenses\", as\n  defined by the Mozilla Public License, v. 2.0.\n"
  },
  {
    "path": "README.md",
    "content": "<!--\n     This Source Code Form is subject to the terms of the Mozilla Public\n     License, v. 2.0. If a copy of the MPL was not distributed with this\n     file, You can obtain one at https://mozilla.org/MPL/2.0/.\n-->\n\n<p align=\"center\">\n<img src=\"res/ocm-icon.png\" width=320>\n<img src=\"res/ocm-banner.png\" width=1000>\n</p>\n\n## by kernitus and Rayzr522\n\nFine‑tune Minecraft combat, movement, and item balance without breaking your server. OldCombatMechanics is a free, open‑source toolkit for Spigot & Paper that lets you mix 1.8‑style snappiness with modern features, per world and per player.\n\n**Why servers pick OCM** ✨\n- 🧩 **Modular:** enable only what you need: cooldowns, tool damage, knockback, shields, potions, reach, sounds, more.\n- 🚀 **Performant:** lean listeners only enabled as needed; reflection lookups are cached and recurring tasks are minimised (shared where possible) to keep tick time low on busy PvP servers.\n- 🗺️ **Modesets:** ship different rules for different worlds or players; perfect for mixed PvP/PvE, minigames, or duels.\n- ⏪ **Backwards‑friendly:** runs on Java 8+, supports 1.9 to latest; integrates cleanly with PlaceholderAPI and PacketEvents.\n- ✅ **Tested for you:** live integration tests run real Paper servers across multiple versions every build.\n- 💸 **Zero cost:** fully open source, optional basic telemetry (bStats only), no paywalls.\n\n**Quick start** ⚡\n1. Drop the jar into `plugins/` (Spigot or Paper-derivatives 1.9+).\n2. Restart and edit `config.yml` to pick your modules and modesets.\n3. Use `/ocm reload` to apply changes instantly.\n4. Hand players `/ocm modeset <name>` to let them choose their ruleset.\n\n<p align=\"center\">\n  <a href=\"https://hangar.papermc.io/kernitus/OldCombatMechanics\">\n    <img src=\"res/paper.png\" alt=\"Paper\" height=\"100\">\n  </a>\n  <a href=\"https://www.spigotmc.org/resources/19510/\">\n    <img src=\"res/spigot.png\" alt=\"Spigot\" height=\"100\">\n  </a>\n  <a href=\"https://dev.bukkit.org/projects/oldcombatmechanics\">\n    <img src=\"res/bukkit.png\" alt=\"Bukkit\" height=\"100\">\n  </a>\n</p>\n\n<hr/>\n\n## 🧰 Modesets\n- Per-player/per-world presets that decide which features are active; each world has an allowed list and a default modeset.\n- Let players pick ( `/ocm modeset <name>` ) to run, for example, 1.8-style PvP in an arena world while keeping vanilla rules in survival.\n\n## ⚙ Configurable Features\nFeatures are grouped in `module`s as listed below, and can be individually configured and disabled. Disabled modules will have no impact on server performance.\n\n#### ⚔ Combat\n*Tweak timing, damage, and reach.*\n- **Attack cooldown:** adjust or remove 1.9+ cooldown\n- **Attack frequency:** set global hit delay\n- **Tool damage:** pre-1.9 weapon values\n- **Attack range (Paper 1.21.11+):** 1.8-style reach\n- **Critical hits:** control crit multiplier\n- **Player regen:** tune regen rates\n\n#### 🤺 Armour\n*Balance defence and wear.*\n- **Armour strength:** scale armour protection\n- **Armour durability:** change durability loss\n\n#### 🛡 Swords & Shields\n*Control block and sweep behaviour.*\n- **Sword blocking:** restore old right-click block; on Paper 1.21.2+ we also add the native sword blocking animation via the consumable component\n- **Shield damage reduction:** scale shield protection\n- **Sword sweep:** enable or disable sweeps\n- **Sword sweep particles:** hide or show sweep visuals\n\n#### 🌬 Knockback\n*Shape knockback per source.*\n- **Player knockback:** adjust PvP knockback\n- **Fishing knockback:** fishing-rod knockback\n- **Fishing rod velocity:** pull speed\n- **Projectile knockback:** arrows and other projectiles\n\n#### 🧙 Gapples & Potions\n*Change consumable power.*\n- **Golden apple crafting and effects:** notch and normal\n- **Potion effects and duration:** old-style values\n- **Chorus fruit:** teleport behaviour and range\n\n#### ❌ New feature disabling\n*Toggle later-version mechanics.*\n- **Item crafting:** block selected recipes\n- **Offhand:** disable offhand use\n- **New attack sounds:** mute new swing sounds\n- **Enderpearl cooldown:** enable or remove cooldown\n- **Brewing stand refuel:** alter fuel use\n- **Burn delay:** adjust fire tick delay\n\n## 🔌 Compatibility & Testing\n- OCM targets Spigot 1.9+ and runs on Java 8 and up.\n- We stick to Spigot/Paper APIs for forward compatibility; NMS/reflection is used only when necessary.\n- Integration tests boot real servers on 1.9.4, 1.12, 1.19.2, and 1.21.11 each build to verify behaviour.\n- Most plugins work fine with OCM. Explicitly tested integrations include PlaceholderAPI (see [wiki](https://github.com/kernitus/BukkitOldCombatMechanics/wiki/PlaceholderAPI)).\n\n## 🧾 Licence\n- Source code in this repository is under the Mozilla Public License 2.0 (MPL‑2.0).\n- Pre-built jars bundle PacketEvents (GPLv3). Those binary distributions are provided under GPLv3 terms due to the included dependency.\n- If you build a jar without PacketEvents, you may distribute that build under MPL‑2.0, subject to its terms.\n\n## ⚡ Development Builds\nOftentimes a particular bug fix or feature has already been implemented, but a new version of OCM has not been released\nyet. You can find the most up-to-date version of the plugin\non [Hangar](https://hangar.papermc.io/kernitus/OldCombatMechanics/versions?channel=Snapshot&platform=PAPER).\n\n\n## 🤝 Contributions\nIf you are interested in contributing, please [check this page first](.github/CONTRIBUTING.md).\n<hr/>\n\n\n<a href=\"https://bstats.org/plugin/bukkit/OldCombatMechanics\">\n    <img src=\"https://bstats.org/signatures/bukkit/OldCombatMechanics.svg\" alt=\"bStats\">\n</a>\n"
  },
  {
    "path": "build.gradle.kts",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\nimport com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar\nimport groovy.json.JsonSlurper\nimport io.papermc.hangarpublishplugin.model.Platforms\nimport org.gradle.api.Action\nimport org.gradle.api.attributes.java.TargetJvmVersion\nimport org.gradle.api.file.FileCopyDetails\nimport org.jetbrains.kotlin.gradle.dsl.JvmTarget\nimport org.jetbrains.kotlin.gradle.tasks.KotlinCompile\nimport xyz.jpenilla.runpaper.task.RunServer\nimport java.io.ByteArrayOutputStream\nimport java.io.Closeable\nimport java.io.Serializable\nimport java.net.URI\nimport java.nio.file.Files\nimport java.security.MessageDigest\n\nval paperVersion: List<String> =\n    (property(\"gameVersions\") as String)\n        .split(\",\")\n        .map { it.trim() }\n\nplugins {\n    `java-library`\n    kotlin(\"jvm\") version \"2.3.0\"\n    id(\"com.gradleup.shadow\") version \"9.3.0\"\n    id(\"xyz.jpenilla.run-paper\") version \"3.0.2\"\n    idea\n    id(\"io.papermc.hangar-publish-plugin\") version \"0.1.4\"\n}\n\n// Make sure javadocs are available to IDE\nidea {\n    module {\n        isDownloadJavadoc = true\n        isDownloadSources = true\n    }\n}\n\nrepositories {\n    mavenCentral()\n    maven(\"https://repo.papermc.io/repository/maven-public/\")\n    // Spigot API\n    maven(\"https://hub.spigotmc.org/nexus/content/repositories/snapshots/\")\n    maven(\"https://oss.sonatype.org/content/repositories/snapshots\")\n    maven(\"https://oss.sonatype.org/content/repositories/central\")\n    // PacketEvents\n    maven(\"https://repo.codemc.io/repository/maven-releases/\")\n    maven(\"https://repo.codemc.io/repository/maven-snapshots/\")\n    // Placeholder API\n    maven(\"https://repo.extendedclip.com/content/repositories/placeholderapi/\")\n    // CodeMC Repo for bStats\n    maven(\"https://repo.codemc.org/repository/maven-public/\")\n    // Auth library from Minecraft\n    maven(\"https://libraries.minecraft.net/\")\n}\n\ngroup = \"kernitus.plugin.OldCombatMechanics\"\nversion = \"2.5.0-beta\" // x-release-please-version\ndescription = \"OldCombatMechanics\"\n\njava {\n    toolchain {\n        // We can build with Java 17 but still support MC >=1.9\n        // This is because MC >=1.9 server can be run with higher Java versions\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\nsourceSets {\n    val integrationTest by creating {\n        kotlin.setSrcDirs(listOf(\"src/integrationTest/kotlin\"))\n        resources.setSrcDirs(listOf(\"src/integrationTest/resources\"))\n        compileClasspath += main.get().output\n        runtimeClasspath += output + main.get().output\n    }\n}\n\nconfigurations {\n    val integrationTestImplementation by getting {\n        extendsFrom(configurations.implementation.get())\n    }\n    create(\"integrationTestServerPlugins\") {\n        isCanBeConsumed = false\n        isCanBeResolved = true\n    }\n}\n\nconfigurations.named(\"compileClasspath\") {\n    attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)\n}\nconfigurations.named(\"integrationTestCompileClasspath\") {\n    attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)\n}\n\ndependencies {\n    implementation(\"org.bstats:bstats-bukkit:3.1.0\")\n    // Shaded in by Bukkit\n    compileOnly(\"io.netty:netty-all:4.1.130.Final\")\n    // Placeholder API\n    compileOnly(\"me.clip:placeholderapi:2.11.6\")\n    // For BSON file serialisation\n    implementation(\"org.mongodb:bson:5.6.2\")\n    // Spigot\n    compileOnly(\"org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT\")\n    // JSR-305 annotations (javax.annotation.Nullable)\n    compileOnly(\"com.google.code.findbugs:jsr305:3.0.2\")\n    // PacketEvents\n    implementation(\"com.github.retrooper:packetevents-spigot:2.11.2\")\n    // XSeries\n    implementation(\"com.github.cryptomorin:XSeries:13.6.0\")\n\n    // For ingametesting\n    // Mojang mappings for NMS\n    /*\n    compileOnly(\"com.mojang:authlib:6.0.54\")\n    paperweight.paperDevBundle(\"1.19.2-R0.1-SNAPSHOT\")\n    // For reflection remapping\n    implementation(\"xyz.jpenilla:reflection-remapper:0.1.3\")\n     */\n\n    // Integration test dependencies\n    implementation(\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.0\")\n    add(\"integrationTestImplementation\", \"org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.0\")\n    add(\"integrationTestImplementation\", \"org.jetbrains.kotlin:kotlin-test:2.3.0\")\n    add(\"integrationTestImplementation\", \"org.jetbrains.kotlin:kotlin-reflect:2.3.0\")\n    add(\"integrationTestImplementation\", \"io.kotest:kotest-runner-junit5-jvm:5.9.1\")\n    add(\"integrationTestImplementation\", \"io.kotest:kotest-assertions-core-jvm:5.9.1\")\n    add(\"integrationTestImplementation\", \"net.kyori:adventure-api:4.26.1\")\n    add(\"integrationTestImplementation\", \"xyz.jpenilla:reflection-remapper:0.1.3\")\n    add(\"integrationTestCompileOnly\", \"org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT\")\n    add(\"integrationTestCompileOnly\", \"com.mojang:authlib:6.0.54\")\n    add(\"integrationTestCompileOnly\", \"io.netty:netty-all:4.1.130.Final\")\n}\n\n// Substitute ${pluginVersion} in plugin.yml with version defined above\nclass ExpandPluginVersionAction(\n    private val version: String,\n) : Action<FileCopyDetails>,\n    Serializable {\n    override fun execute(details: FileCopyDetails) {\n        details.expand(mapOf(\"pluginVersion\" to version))\n    }\n}\n\nval pluginVersion = project.version.toString()\nval expandPluginVersionAction = ExpandPluginVersionAction(pluginVersion)\ntasks.named<Copy>(\"processResources\") {\n    inputs.property(\"pluginVersion\", pluginVersion)\n    filesMatching(\"plugin.yml\", expandPluginVersionAction)\n}\n\ntasks.withType<JavaCompile> {\n    options.encoding = \"UTF-8\"\n    options.release.set(8)\n}\n\nval shadowJarTask =\n    tasks.named<ShadowJar>(\"shadowJar\") {\n        dependsOn(\"jar\")\n        archiveFileName.set(\"${project.name}.jar\")\n        dependencies {\n            exclude(dependency(\"org.jetbrains.kotlin:.*\"))\n            relocate(\"org.bstats\", \"kernitus.plugin.OldCombatMechanics.lib.bstats\")\n            relocate(\"com.cryptomorin.xseries\", \"kernitus.plugin.OldCombatMechanics.lib.xseries\")\n            relocate(\"com.github.retrooper.packetevents\", \"kernitus.plugin.OldCombatMechanics.lib.packetevents.api\")\n            relocate(\"io.github.retrooper.packetevents\", \"kernitus.plugin.OldCombatMechanics.lib.packetevents.impl\")\n        }\n    }\n\n// For ingametesting\n/*\ntasks.reobfJar {\n    outputJar.set(File(buildDir, \"libs/${project.name}.jar\"))\n}\n */\n\ntasks.assemble {\n    // For ingametesting\n    // dependsOn(\"reobfJar\")\n    dependsOn(\"shadowJar\")\n}\n\nkotlin {\n    jvmToolchain(17)\n}\n\ntasks.withType<KotlinCompile>().configureEach {\n    compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)\n}\n\nval relocateIntegrationTestClasses =\n    tasks.register<ShadowJar>(\"relocateIntegrationTestClasses\") {\n        archiveClassifier.set(\"tests-relocated\")\n        dependsOn(\"compileIntegrationTestKotlin\")\n        configurations = emptyList()\n        from(sourceSets[\"integrationTest\"].output)\n        relocate(\"com.github.retrooper.packetevents\", \"kernitus.plugin.OldCombatMechanics.lib.packetevents.api\")\n        relocate(\"io.github.retrooper.packetevents\", \"kernitus.plugin.OldCombatMechanics.lib.packetevents.impl\")\n    }\n\nval integrationTestJarTask =\n    tasks.register<Jar>(\"integrationTestJar\") {\n        archiveClassifier.set(\"tests\")\n        duplicatesStrategy = DuplicatesStrategy.EXCLUDE\n\n        dependsOn(relocateIntegrationTestClasses)\n\n        from(relocateIntegrationTestClasses.flatMap { it.archiveFile }.map { zipTree(it.asFile) })\n\n        project.configurations[\"integrationTestRuntimeClasspath\"].forEach { file: File ->\n            if (file.name.contains(\"packetevents\", ignoreCase = true)) {\n                return@forEach\n            }\n            from(if (file.isDirectory) file else zipTree(file))\n        }\n\n        exclude(\"META-INF/*.SF\")\n        exclude(\"META-INF/*.DSA\")\n        exclude(\"META-INF/*.RSA\")\n        exclude(\"META-INF/*.EC\")\n        exclude(\"META-INF/*.MF\")\n        exclude(\"module-info.class\")\n        exclude(\"META-INF/versions/**/module-info.class\")\n    }\n\nval integrationTestMinecraftVersion =\n    (findProperty(\"integrationTestMinecraftVersion\") as String?) ?: \"1.19.2\"\n\nval defaultIntegrationTestVersions =\n    listOf(integrationTestMinecraftVersion, \"1.21.11\", \"1.12\", \"1.9.4\")\n        .distinct()\n\nval integrationTestVersions: List<String> =\n    (findProperty(\"integrationTestVersions\") as String?)\n        ?.split(\",\")\n        ?.map { it.trim() }\n        ?.filter { it.isNotEmpty() }\n        ?.ifEmpty { defaultIntegrationTestVersions }\n        ?: defaultIntegrationTestVersions\n\nval integrationTestJavaVersionLegacy =\n    (findProperty(\"integrationTestJavaVersionLegacy\") as String?)?.toInt() ?: 17\nval integrationTestJavaVersionLegacyPre13 =\n    (findProperty(\"integrationTestJavaVersionLegacyPre13\") as String?)?.toInt() ?: 8\nval integrationTestJavaVersionLegacy16 =\n    (findProperty(\"integrationTestJavaVersionLegacy16\") as String?)?.toInt() ?: 11\nval integrationTestJavaVersionModern =\n    (findProperty(\"integrationTestJavaVersionModern\") as String?)?.toInt() ?: 25\n\nfun parseMinecraftVersion(version: String): Triple<Int, Int, Int> {\n    val parts = version.split(\".\")\n    val major = parts.getOrNull(0)?.toIntOrNull() ?: 0\n    val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0\n    val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0\n    return Triple(major, minor, patch)\n}\n\nfun needsLegacyVanillaJar(version: String): Boolean {\n    val (major, minor, _) = parseMinecraftVersion(version)\n    return major == 1 && minor <= 12\n}\n\nfun requiresModernJava(version: String): Boolean {\n    val (major, minor, patch) = parseMinecraftVersion(version)\n    if (major > 1) return true\n    if (minor > 20) return true\n    return minor == 20 && patch >= 5\n}\n\nfun requiredJavaVersion(version: String): Int {\n    if (needsLegacyVanillaJar(version)) return integrationTestJavaVersionLegacyPre13\n    val (_, minor, _) = parseMinecraftVersion(version)\n    if (minor <= 16) return integrationTestJavaVersionLegacy16\n    return if (requiresModernJava(version)) integrationTestJavaVersionModern else integrationTestJavaVersionLegacy\n}\n\ndata class KotestSummary(\n    val specsPassed: Int?,\n    val specsFailed: Int?,\n    val specsTotal: Int?,\n    val testsPassed: Int?,\n    val testsFailed: Int?,\n    val testsIgnored: Int?,\n    val testsTotal: Int?,\n    val failures: List<String>,\n    val failureDetails: List<String>,\n)\n\nfun parseKotestSummary(logFile: File): KotestSummary? {\n    if (!logFile.exists()) return null\n\n    val lines = logFile.readLines()\n    var inFailures = false\n    var blockContext: String? = null // \"specs\" or \"tests\"\n\n    var specsPassed: Int? = null\n    var specsFailed: Int? = null\n    var specsTotal: Int? = null\n\n    var testsPassed: Int? = null\n    var testsFailed: Int? = null\n    var testsIgnored: Int? = null\n    var testsTotal: Int? = null\n\n    val failures = mutableListOf<String>()\n    val failureDetails = mutableListOf<String>()\n\n    val numberLine = Regex(\"^(\\\\d+) (passed|failed|ignored|total)$\")\n    val inlineSpecsLine = Regex(\"^Specs:\\\\s*(\\\\d+) passed,\\\\s*(\\\\d+) failed,\\\\s*(\\\\d+) total$\")\n    val inlineTestsLine = Regex(\"^Tests:\\\\s*(\\\\d+) passed,\\\\s*(\\\\d+) failed,\\\\s*(\\\\d+) ignored,\\\\s*(\\\\d+) total$\")\n    val stackTopLine = Regex(\"^\\\\s*[^\\\\s].*\\\\(([^)]+:\\\\d+)\\\\)\\\\s*$\")\n\n    var lastTestName: String? = null\n    var pendingFailureName: String? = null\n    var pendingFailureMessage: String? = null\n\n    for (raw in lines) {\n        val line = raw.substringAfter(\"]:\", raw).trim()\n\n        when {\n            line.startsWith(\">> There were test failures\") -> {\n                inFailures = true\n                blockContext = null\n            }\n\n            line.startsWith(\"Specs:\") -> {\n                inFailures = false\n                val inline = inlineSpecsLine.matchEntire(line)\n                if (inline != null) {\n                    specsPassed = inline.groupValues[1].toInt()\n                    specsFailed = inline.groupValues[2].toInt()\n                    specsTotal = inline.groupValues[3].toInt()\n                    blockContext = null\n                } else {\n                    blockContext = \"specs\"\n                }\n            }\n\n            line.startsWith(\"Tests:\") -> {\n                inFailures = false\n                val inline = inlineTestsLine.matchEntire(line)\n                if (inline != null) {\n                    testsPassed = inline.groupValues[1].toInt()\n                    testsFailed = inline.groupValues[2].toInt()\n                    testsIgnored = inline.groupValues[3].toInt()\n                    testsTotal = inline.groupValues[4].toInt()\n                    blockContext = null\n                } else {\n                    blockContext = \"tests\"\n                }\n            }\n\n            inFailures -> {\n                val cleaned = line.removePrefix(\"-\").trim()\n                if (cleaned.isNotBlank()) {\n                    failures.add(cleaned)\n                }\n            }\n\n            line.startsWith(\"- \") -> {\n                lastTestName = line.removePrefix(\"-\").trim()\n            }\n\n            line == \"FAILED\" -> {\n                pendingFailureName = lastTestName\n                pendingFailureMessage = null\n            }\n\n            pendingFailureName != null && pendingFailureMessage == null && line.isNotBlank() -> {\n                // First line after FAILED is usually the assertion message.\n                pendingFailureMessage = line\n            }\n\n            pendingFailureName != null && pendingFailureMessage != null -> {\n                val match = stackTopLine.matchEntire(line)\n                if (match != null) {\n                    val location = match.groupValues[1]\n                    val name = pendingFailureName!!\n                    val message = pendingFailureMessage!!\n                    failureDetails.add(\"$name: $message ($location)\")\n                    pendingFailureName = null\n                    pendingFailureMessage = null\n                }\n            }\n\n            blockContext != null -> {\n                val match = numberLine.matchEntire(line)\n                if (match != null) {\n                    val value = match.groupValues[1].toInt()\n                    when (blockContext) {\n                        \"specs\" -> {\n                            when (match.groupValues[2]) {\n                                \"passed\" -> specsPassed = value\n                                \"failed\" -> specsFailed = value\n                                \"total\" -> specsTotal = value\n                            }\n                        }\n\n                        \"tests\" -> {\n                            when (match.groupValues[2]) {\n                                \"passed\" -> testsPassed = value\n                                \"failed\" -> testsFailed = value\n                                \"ignored\" -> testsIgnored = value\n                                \"total\" -> testsTotal = value\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    if (\n        specsPassed == null && specsFailed == null && specsTotal == null &&\n        testsPassed == null && testsFailed == null && testsIgnored == null && testsTotal == null &&\n        failures.isEmpty()\n    ) {\n        return null\n    }\n\n    return KotestSummary(\n        specsPassed,\n        specsFailed,\n        specsTotal,\n        testsPassed,\n        testsFailed,\n        testsIgnored,\n        testsTotal,\n        failures,\n        failureDetails.distinct(),\n    )\n}\n\nfun sha1(file: File): String {\n    val digest = MessageDigest.getInstance(\"SHA-1\")\n    file.inputStream().use { input ->\n        val buffer = ByteArray(8192)\n        while (true) {\n            val read = input.read(buffer)\n            if (read <= 0) break\n            digest.update(buffer, 0, read)\n        }\n    }\n    return digest.digest().joinToString(\"\") { \"%02x\".format(it) }\n}\n\ntasks.register(\"integrationTest\") {\n    group = \"verification\"\n    description = \"Runs integration tests against all configured Paper versions.\"\n    dependsOn(\"integrationTestMatrix\")\n}\n\ntasks.named<RunServer>(\"runServer\") {\n    enabled = false\n    description = \"Disabled. Use integrationTest/integrationTestMatrix instead.\"\n}\n\ntasks.withType<RunServer>().configureEach {\n    notCompatibleWithConfigurationCache(\"run-paper tasks access Project at execution time.\")\n}\n\nval integrationTestMatrixTasks = mutableListOf<TaskProvider<Task>>()\nvar previousMatrixTask: TaskProvider<Task>? = null\n\nval kotestSpecFilterProvider = providers.systemProperty(\"kotest.filter.specs\")\nval kotestTestFilterProvider = providers.systemProperty(\"kotest.filter.tests\")\n\nfun versionTaskSuffix(version: String): String = version.replace(Regex(\"[^A-Za-z0-9]\"), \"_\")\n\nfor (version in integrationTestVersions) {\n    val suffix = versionTaskSuffix(version)\n    val runDir = file(\"run/$version\")\n    val resultFile = runDir.resolve(\"plugins/OldCombatMechanicsTest/test-results.txt\")\n    val failuresFile = runDir.resolve(\"plugins/OldCombatMechanicsTest/test-failures.txt\")\n    val vanillaCacheFile = runDir.resolve(\"cache/mojang_$version.jar\")\n    val logFile = layout.buildDirectory.file(\"integration-test-logs/$suffix.log\")\n\n    val writePropsTask =\n        tasks.register<WriteProperties>(\"writeProperties$suffix\") {\n            encoding = \"UTF-8\"\n            property(\"online-mode\", false)\n            destinationFile.set(runDir.resolve(\"server.properties\"))\n        }\n\n    val downloadVanillaTask =\n        if (needsLegacyVanillaJar(version)) {\n            tasks.register(\"downloadVanilla$suffix\") {\n                outputs.file(vanillaCacheFile)\n                notCompatibleWithConfigurationCache(\"Downloads vanilla server jar for legacy Paper versions.\")\n                doLast {\n                    val slurper = JsonSlurper()\n                    val manifestText =\n                        URI(\"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json\")\n                            .toURL()\n                            .readText()\n                    val manifest = slurper.parseText(manifestText) as Map<*, *>\n                    val versionsList =\n                        manifest[\"versions\"] as? List<Map<*, *>>\n                            ?: throw GradleException(\"Invalid Mojang manifest format: missing 'versions' list.\")\n                    val versionEntry =\n                        versionsList.firstOrNull { it[\"id\"] == version }\n                            ?: throw GradleException(\"Minecraft version '$version' not found in Mojang manifest.\")\n                    val versionUrl = versionEntry[\"url\"] as String\n                    val versionMetaText = URI(versionUrl).toURL().readText()\n                    val versionMeta = slurper.parseText(versionMetaText) as Map<*, *>\n                    val downloads = versionMeta[\"downloads\"] as Map<*, *>\n                    val serverInfo = downloads[\"server\"] as Map<*, *>\n                    val serverUrl = serverInfo[\"url\"] as String\n                    val serverSha1 = serverInfo[\"sha1\"] as String\n\n                    if (vanillaCacheFile.exists()) {\n                        val existingSha1 = sha1(vanillaCacheFile)\n                        if (existingSha1.equals(serverSha1, ignoreCase = true)) {\n                            return@doLast\n                        }\n                    } else {\n                        vanillaCacheFile.parentFile.mkdirs()\n                    }\n\n                    val tmpFile = Files.createTempFile(\"mc-server-$version-\", \".jar\").toFile()\n                    URI(serverUrl).toURL().openStream().use { input ->\n                        tmpFile.outputStream().use { output -> input.copyTo(output) }\n                    }\n                    val downloadedSha1 = sha1(tmpFile)\n                    if (!downloadedSha1.equals(serverSha1, ignoreCase = true)) {\n                        tmpFile.delete()\n                        throw GradleException(\n                            \"Downloaded Minecraft server jar hash mismatch for $version. Expected $serverSha1, got $downloadedSha1.\",\n                        )\n                    }\n                    tmpFile.copyTo(vanillaCacheFile, overwrite = true)\n                    tmpFile.delete()\n                }\n            }\n        } else {\n            null\n        }\n\n    val runServerTask =\n        tasks.register<RunServer>(\"runServer$suffix\") {\n            dependsOn(writePropsTask)\n            downloadVanillaTask?.let { dependsOn(it) }\n            runDirectory.set(runDir)\n            minecraftVersion(version)\n            jvmArgs(\"-Dcom.mojang.eula.agree=true\")\n            kotestSpecFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {\n                jvmArgs(\"-Dkotest.filter.specs=$it\")\n            }\n            kotestTestFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {\n                jvmArgs(\"-Dkotest.filter.tests=$it\")\n            }\n            if (needsLegacyVanillaJar(version)) {\n                // Skip the legacy Paper \"outdated build\" startup sleep.\n                jvmArgs(\"-DIReallyKnowWhatIAmDoingISwear=true\")\n            }\n            javaLauncher.set(\n                javaToolchains.launcherFor {\n                    languageVersion.set(JavaLanguageVersion.of(requiredJavaVersion(version)))\n                },\n            )\n\n            pluginJars.from(shadowJarTask.flatMap { it.archiveFile })\n            pluginJars.from(integrationTestJarTask.flatMap { it.archiveFile })\n            pluginJars.from(configurations[\"integrationTestServerPlugins\"])\n\n            doFirst {\n                val log = logFile.get().asFile\n                log.parentFile.mkdirs()\n                val stream = log.outputStream()\n                standardOutput = stream\n                errorOutput = stream\n            }\n\n            doLast {\n                (standardOutput as? Closeable)?.close()\n            }\n\n            doFirst {\n                if (resultFile.exists()) {\n                    resultFile.delete()\n                }\n                if (failuresFile.exists()) {\n                    failuresFile.delete()\n                }\n                val ocmConfigFile = runDir.resolve(\"plugins/OldCombatMechanics/config.yml\")\n                if (ocmConfigFile.exists()) {\n                    ocmConfigFile.delete()\n                }\n            }\n        }\n\n    val checkTask =\n        tasks.register(\"checkTestResults$suffix\") {\n            doLast {\n                if (!resultFile.exists()) {\n                    throw GradleException(\"Test results file not found for $version. Tests may not have run correctly.\")\n                }\n                val result = resultFile.readText().trim()\n                val log = logFile.get().asFile\n                val summary = parseKotestSummary(log)\n                summary?.let {\n                    val parts = mutableListOf<String>()\n                    if (it.specsTotal != null) {\n                        parts.add(\"Specs: ${it.specsPassed ?: \"?\"} passed, ${it.specsFailed ?: \"?\"} failed, ${it.specsTotal} total\")\n                    }\n                    if (it.testsTotal != null) {\n                        parts.add(\n                            \"Tests: ${it.testsPassed ?: \"?\"} passed, ${it.testsFailed ?: \"?\"} failed, ${it.testsIgnored ?: 0} ignored, ${it.testsTotal} total\",\n                        )\n                    }\n                    if (it.failures.isNotEmpty()) {\n                        parts.add(\"Failures: ${it.failures.joinToString(\", \")}\")\n                    }\n                    if (it.failureDetails.isNotEmpty()) {\n                        parts.add(\"Reasons: ${it.failureDetails.take(2).joinToString(\"; \")}\")\n                    }\n                    if (parts.isNotEmpty()) {\n                        logger.lifecycle(\"[$version] ${parts.joinToString(\" | \")}\")\n                    }\n                } ?: run {\n                    val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath\n                    logger.lifecycle(\"[$version] No Kotest summary parsed. Full log: $rel\")\n                }\n                run {\n                    val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath\n                    logger.lifecycle(\"[$version] Log: $rel\")\n                }\n                if (failuresFile.exists()) {\n                    val lines = failuresFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }\n                    if (lines.isNotEmpty()) {\n                        logger.lifecycle(\"[$version] Failure details: ${lines.take(5).joinToString(\" | \")}\")\n                    }\n                }\n                if (result == \"FAIL\") {\n                    throw GradleException(\"Integration tests failed for $version.\")\n                } else if (result != \"PASS\") {\n                    throw GradleException(\"Unknown test result for $version: $result\")\n                }\n                logger.lifecycle(\"Integration tests passed for $version.\")\n            }\n        }\n\n    val testTask =\n        tasks.register(\"integrationTest$suffix\") {\n            group = \"verification\"\n            description = \"Runs integration tests with a live Paper server ($version).\"\n            dependsOn(shadowJarTask, integrationTestJarTask, runServerTask)\n            finalizedBy(checkTask)\n        }\n\n    val priorTask = previousMatrixTask\n    if (priorTask != null) {\n        // Chain tasks to enforce order and fail fast.\n        testTask.configure { dependsOn(priorTask) }\n    }\n    previousMatrixTask = testTask\n    integrationTestMatrixTasks.add(testTask)\n}\n\ntasks.register(\"integrationTestMatrix\") {\n    group = \"verification\"\n    description = \"Runs integration tests against multiple Paper versions.\"\n    dependsOn(integrationTestMatrixTasks)\n}\n\nval versionStringProvider = providers.provider { project.version.toString() }\nval isReleaseProvider = versionStringProvider.map { !it.contains('-') }\n\nval gitShortHashProvider =\n    providers\n        .exec {\n            commandLine(\"git\", \"rev-parse\", \"--short\", \"HEAD\")\n        }.standardOutput.asText\n        .map { it.trim() }\n\nval gitChangelogProvider =\n    providers\n        .exec {\n            commandLine(\"git\", \"log\", \"-1\", \"--pretty=%B\")\n        }.standardOutput.asText\n        .map { it.trim() }\n\nval suffixedVersionProvider =\n    providers.provider {\n        val version = project.version.toString()\n        if (!version.contains('-')) {\n            version\n        } else {\n            \"$version+${gitShortHashProvider.get()}\"\n        }\n    }\n\nval changelogProvider = providers.environmentVariable(\"HANGAR_CHANGELOG\").orElse(gitChangelogProvider)\n\ntasks.register(\"printIsRelease\") {\n    doLast {\n        println(if (!project.version.toString().contains('-')) \"true\" else \"false\")\n    }\n}\n\nhangarPublish {\n    publications.register(\"plugin\") {\n        version.set(suffixedVersionProvider)\n        channel.set(isReleaseProvider.map { if (it) \"Release\" else \"Snapshot\" })\n        id.set(\"OldCombatMechanics\")\n        apiKey.set(System.getenv(\"HANGAR_API_TOKEN\"))\n        changelog.set(changelogProvider)\n        platforms {\n            register(Platforms.PAPER) {\n                jar.set(tasks.shadowJar.flatMap { it.archiveFile })\n                platformVersions.set(paperVersion)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "gradle.properties",
    "content": "# This Source Code Form is subject to the terms of the Mozilla Public\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\ngameVersions=1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2, 1.18.1, 1.18, 1.17, 1.16, 1.15, 1.14, 1.13, 1.12, 1.11, 1.10, 1.9\n\n# Disable configuration cache (run-paper tasks are not compatible).\norg.gradle.configuration-cache=false\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\n@rem you may not use this file except in compliance with the License.\n@rem You may obtain a copy of the License at\n@rem\n@rem      https://www.apache.org/licenses/LICENSE-2.0\n@rem\n@rem Unless required by applicable law or agreed to in writing, software\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n@rem See the License for the specific language governing permissions and\n@rem limitations under the License.\n@rem\n@rem SPDX-License-Identifier: Apache-2.0\n@rem\n\n@if \"%DEBUG%\"==\"\" @echo off\n@rem ##########################################################################\n@rem\n@rem  Gradle startup script for Windows\n@rem\n@rem ##########################################################################\n\n@rem Set local scope for the variables with windows NT shell\nif \"%OS%\"==\"Windows_NT\" setlocal\n\nset DIRNAME=%~dp0\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\n@rem This is normally unused\nset APP_BASE_NAME=%~n0\nset APP_HOME=%DIRNAME%\n\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\n\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\n\n@rem Find java.exe\nif defined JAVA_HOME goto findJavaFromJavaHome\n\nset JAVA_EXE=java.exe\n%JAVA_EXE% -version >NUL 2>&1\nif %ERRORLEVEL% equ 0 goto execute\n\necho. 1>&2\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\necho. 1>&2\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\necho location of your Java installation. 1>&2\n\ngoto fail\n\n:findJavaFromJavaHome\nset JAVA_HOME=%JAVA_HOME:\"=%\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\n\nif exist \"%JAVA_EXE%\" goto execute\n\necho. 1>&2\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\necho. 1>&2\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\necho location of your Java installation. 1>&2\n\ngoto fail\n\n:execute\n@rem Setup the command line\n\n\n\n@rem Execute Gradle\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -jar \"%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\" %*\n\n:end\n@rem End local scope for the variables with windows NT shell\nif %ERRORLEVEL% equ 0 goto mainEnd\n\n:fail\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\nrem the _cmd.exe /c_ return code!\nset EXIT_CODE=%ERRORLEVEL%\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\nexit /b %EXIT_CODE%\n\n:mainEnd\nif \"%OS%\"==\"Windows_NT\" endlocal\n\n:omega\n"
  },
  {
    "path": "settings.gradle.kts",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\nrootProject.name = \"OldCombatMechanics\"\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCompat.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport java.lang.reflect.Method\nimport java.util.concurrent.ConcurrentHashMap\n\nfun attackCompat(attacker: Player, target: Entity) {\n    val apiAttack = attacker.javaClass.methods.firstOrNull { method ->\n        method.name == \"attack\" &&\n            method.parameterCount == 1 &&\n            Entity::class.java.isAssignableFrom(method.parameterTypes[0])\n    }\n    val useApiAttack = Reflector.versionIsNewerOrEqualTo(1, 12, 0)\n    if (useApiAttack && apiAttack != null) {\n        val beforeApiAttack = captureLivingAttackSignal(target)\n        try {\n            val apiResult = apiAttack.invoke(attacker, target)\n            if (apiAttack.returnType == java.lang.Boolean.TYPE && apiResult == false) {\n                // Explicit attack failure; continue with NMS candidates.\n            } else if (beforeApiAttack == null) {\n                return\n            } else {\n                val afterApiAttack = captureLivingAttackSignal(target)\n                if (hasObservableHit(beforeApiAttack, afterApiAttack)) {\n                    return\n                }\n            }\n        } catch (ignored: Exception) {\n            // Fall through to NMS-based attack.\n        }\n    }\n\n    // Fall back to NMS attack on legacy servers.\n    val handleMethod = attacker.javaClass.methods.firstOrNull { method ->\n        method.name == \"getHandle\" && method.parameterCount == 0\n    } ?: error(\"Failed to resolve CraftPlayer#getHandle for ${attacker.javaClass.name}\")\n\n    val attackerHandle = handleMethod.invoke(attacker)\n        ?: error(\"CraftPlayer#getHandle returned null for ${attacker.javaClass.name}\")\n\n    val targetHandle = target.javaClass.methods.firstOrNull { method ->\n        method.name == \"getHandle\" && method.parameterCount == 0\n    }?.invoke(target) ?: error(\"Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}\")\n\n    val nmsAttackMethods = resolveNmsAttackMethods(attackerHandle.javaClass, targetHandle.javaClass)\n    var falseResultCount = 0\n    var exceptionCount = 0\n    val attemptedMethods = ArrayList<String>(nmsAttackMethods.size)\n\n    for (method in nmsAttackMethods) {\n        attemptedMethods += \"${method.declaringClass.simpleName}#${method.name}:${method.returnType.simpleName}\"\n        try {\n            val result = method.invoke(attackerHandle, targetHandle)\n            if (method.returnType == java.lang.Boolean.TYPE && result == false) {\n                falseResultCount++\n                continue\n            }\n            return\n        } catch (ignored: Exception) {\n            exceptionCount++\n            // Try the next candidate.\n        }\n    }\n\n    // Legacy fallback: try Bukkit API even if we prefer NMS (helps 1.12 fake players)\n    if (!useApiAttack && apiAttack != null) {\n        runCatching { apiAttack.invoke(attacker, target); return }\n    }\n\n    error(\n        \"Failed to invoke NMS attack for attacker=${attackerHandle.javaClass.name} \" +\n            \"target=${targetHandle.javaClass.name} (candidates=${nmsAttackMethods.size}, \" +\n            \"falseResults=$falseResultCount, exceptions=$exceptionCount, attempted=$attemptedMethods)\"\n    )\n}\n\n\nprivate data class LivingAttackSignal(\n    val health: Double,\n    val lastDamage: Double,\n    val noDamageTicks: Int,\n)\n\nprivate fun captureLivingAttackSignal(entity: Entity): LivingAttackSignal? {\n    val living = entity as? LivingEntity ?: return null\n    return LivingAttackSignal(\n        health = living.health,\n        lastDamage = living.lastDamage,\n        noDamageTicks = living.noDamageTicks,\n    )\n}\n\nprivate fun hasObservableHit(before: LivingAttackSignal, after: LivingAttackSignal?): Boolean {\n    if (after == null) return false\n    if (after.health < before.health) return true\n    if (after.noDamageTicks > before.noDamageTicks) return true\n    return after.lastDamage > 0.0 && after.lastDamage != before.lastDamage\n}\n\nprivate val attackMethodCache = ConcurrentHashMap<Class<*>, List<Method>>()\n\nprivate fun resolveNmsAttackMethods(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {\n    return attackMethodCache.computeIfAbsent(attackerHandleClass) {\n        buildAttackMethodCandidates(attackerHandleClass, targetHandleClass)\n    }\n}\n\nprivate fun buildAttackMethodCandidates(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {\n    // Prefer explicit names if they exist, then fall back to signature-based heuristics.\n    val explicit = listOfNotNull(\n        Reflector.getMethodAssignable(attackerHandleClass, \"attack\", targetHandleClass),\n        Reflector.getMethodAssignable(attackerHandleClass, \"a\", targetHandleClass),\n        Reflector.getMethodAssignable(attackerHandleClass, \"B\", targetHandleClass) // legacy 1.12 variants\n    )\n    if (explicit.isNotEmpty()) {\n        explicit.forEach { it.isAccessible = true }\n        return explicit\n    }\n\n    val candidates = collectAllMethods(attackerHandleClass)\n        .asSequence()\n        .filter { it.parameterCount == 1 }\n        .filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }\n        .filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }\n        .map { method -> method to scoreAttackMethod(method) }\n        .sortedByDescending { it.second }\n        .map { it.first }\n        .toList()\n\n    candidates.forEach { it.isAccessible = true }\n    return candidates\n}\n\nprivate fun collectAllMethods(start: Class<*>): List<Method> {\n    val methods = LinkedHashMap<String, Method>()\n    var current: Class<*>? = start\n    while (current != null) {\n        current.declaredMethods.forEach { method ->\n            methods.putIfAbsent(methodSignature(method), method)\n        }\n        current = current.superclass\n    }\n    start.methods.forEach { method ->\n        methods.putIfAbsent(methodSignature(method), method)\n    }\n    return methods.values.toList()\n}\n\nprivate fun methodSignature(method: Method): String {\n    val params = method.parameterTypes.joinToString(\",\") { it.name }\n    return \"${method.declaringClass.name}#${method.name}($params):${method.returnType.name}\"\n}\n\nprivate fun scoreAttackMethod(method: Method): Int {\n    var score = 0\n    val name = method.name\n    val param = method.parameterTypes[0]\n    val declaring = method.declaringClass.simpleName\n\n    if (name == \"attack\") score += 100\n    if (name == \"a\") score += 80\n\n    if (param.simpleName == \"Entity\") score += 40\n    if (param.simpleName.contains(\"Entity\")) score += 10\n\n    if (method.returnType == Void.TYPE) score += 10\n    if (method.returnType == java.lang.Boolean.TYPE) score += 8\n\n    if (declaring.contains(\"EntityHuman\")) score += 25\n    if (declaring.contains(\"EntityPlayer\")) score += 20\n\n    return score\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCooldownHeldItemIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleAttackCooldown\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.World\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.player.PlayerChangedWorldEvent\nimport org.bukkit.event.player.PlayerItemHeldEvent\nimport org.bukkit.event.player.PlayerJoinEvent\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass AttackCooldownHeldItemIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n        val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackCooldown>().firstOrNull()\n\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun <T> runSync(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()\n            }\n\n        fun currentAttackSpeed(player: Player): Double {\n            val attackSpeedAttribute = checkNotNull(XAttribute.ATTACK_SPEED.get()) { \"Missing attack speed attribute type\" }\n            val attribute = player.getAttribute(attackSpeedAttribute) ?: error(\"Missing attack speed attribute\")\n            return attribute.baseValue\n        }\n\n        fun setModeset(\n            player: Player,\n            world: World,\n            modeset: String,\n        ) {\n            val data = PlayerStorage.getPlayerData(player.uniqueId)\n            data.setModesetForWorld(world.uid, modeset)\n            PlayerStorage.setPlayerData(player.uniqueId, data)\n        }\n\n        fun fireJoin(player: Player) {\n            Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, \"test\"))\n        }\n\n        fun switchHotbar(\n            player: Player,\n            from: Int,\n            to: Int,\n        ) {\n            player.inventory.heldItemSlot = to\n            Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))\n        }\n\n        data class SpawnedPlayer(\n            val fake: FakePlayer,\n            val player: Player,\n        )\n\n        fun spawnFake(world: World): SpawnedPlayer {\n            lateinit var fake: FakePlayer\n            lateinit var player: Player\n            runSync {\n                fake = FakePlayer(testPlugin)\n                fake.spawn(Location(world, 0.0, 100.0, 0.0, 0f, 0f))\n                player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n                player.inventory.clear()\n                player.inventory.heldItemSlot = 0\n                player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                setModeset(player, world, \"old\")\n            }\n            return SpawnedPlayer(fake, player)\n        }\n\n        fun cleanup(spawnedPlayer: SpawnedPlayer) {\n            runSync { spawnedPlayer.fake.removePlayer() }\n        }\n\n        suspend fun waitForPossibleDeferredWork() {\n            delay(2 * 50L)\n        }\n\n        suspend fun withAttackCooldownConfig(\n            genericAttackSpeed: Double,\n            heldItemAttackSpeeds: Map<String, Double>,\n            block: suspend () -> Unit,\n        ) {\n            val disabledModules = ocm.config.getStringList(\"disabled_modules\")\n            val alwaysEnabledModules = ocm.config.getStringList(\"always_enabled_modules\")\n            val modesetsSection = ocm.config.getConfigurationSection(\"modesets\") ?: error(\"Missing 'modesets' section\")\n            val modesetSnapshot =\n                modesetsSection.getKeys(false).associateWith { key ->\n                    ocm.config.getStringList(\"modesets.$key\")\n                }\n            val genericSnapshot = ocm.config.get(\"disable-attack-cooldown.generic-attack-speed\")\n            val heldItemSnapshot =\n                ocm.config.getConfigurationSection(\"disable-attack-cooldown.held-item-attack-speeds\")?.getValues(false)\n                    ?: emptyMap<String, Any?>()\n\n            fun reloadAll() {\n                ocm.saveConfig()\n                Config.reload()\n                ModuleLoader.toggleModules()\n                module?.reload()\n            }\n\n            try {\n                ocm.config.set(\"disable-attack-cooldown.generic-attack-speed\", genericAttackSpeed)\n                ocm.config.set(\"disable-attack-cooldown.held-item-attack-speeds\", null)\n                heldItemAttackSpeeds.forEach { (key, value) ->\n                    ocm.config.set(\"disable-attack-cooldown.held-item-attack-speeds.$key\", value)\n                }\n\n                ocm.config.set(\"disabled_modules\", disabledModules.filterNot { it == \"disable-attack-cooldown\" })\n                ocm.config.set(\"always_enabled_modules\", alwaysEnabledModules.filterNot { it == \"disable-attack-cooldown\" })\n\n                val oldModeset =\n                    ocm.config.getStringList(\"modesets.old\").toMutableList().apply {\n                        if (!contains(\"disable-attack-cooldown\")) add(\"disable-attack-cooldown\")\n                    }\n                val newModeset =\n                    ocm.config.getStringList(\"modesets.new\").toMutableList().apply {\n                        remove(\"disable-attack-cooldown\")\n                    }\n                ocm.config.set(\"modesets.old\", oldModeset)\n                ocm.config.set(\"modesets.new\", newModeset)\n\n                reloadAll()\n                block()\n            } finally {\n                ocm.config.set(\"disabled_modules\", disabledModules)\n                ocm.config.set(\"always_enabled_modules\", alwaysEnabledModules)\n                modesetSnapshot.forEach { (key, list) -> ocm.config.set(\"modesets.$key\", list) }\n                ocm.config.set(\"disable-attack-cooldown.generic-attack-speed\", genericSnapshot)\n                ocm.config.set(\"disable-attack-cooldown.held-item-attack-speeds\", null)\n                heldItemSnapshot.forEach { (key, value) ->\n                    ocm.config.set(\"disable-attack-cooldown.held-item-attack-speeds.$key\", value)\n                }\n                reloadAll()\n            }\n        }\n\n        test(\"applies configured held-item attack speeds and falls back to the generic value on hotbar switch\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))\n                        spawned.player.inventory.setItem(1, ItemStack(Material.STICK))\n                        spawned.player.inventory.heldItemSlot = 0\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync { switchHotbar(spawned.player, from = 0, to = 1) }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"materials without an explicit held-item entry use disable-attack-cooldown.generic-attack-speed\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 13.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (13.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"world and modeset transitions restore vanilla 4.0 when disabled and reapply the held-item target when re-enabled\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val otherWorld = checkNotNull(Bukkit.getWorld(\"world_nether\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n                        setModeset(spawned.player, world, \"old\")\n                        setModeset(spawned.player, otherWorld, \"new\")\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync {\n                        setModeset(spawned.player, world, \"new\")\n                        module?.onModesetChange(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (4.0 plusOrMinus 0.01)\n\n                    runSync {\n                        setModeset(spawned.player, world, \"old\")\n                        module?.onModesetChange(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync {\n                        spawned.player.teleport(Location(otherWorld, 0.0, 100.0, 0.0, 0f, 0f))\n                        Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(spawned.player, world))\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (4.0 plusOrMinus 0.01)\n\n                    runSync {\n                        spawned.player.teleport(Location(world, 0.0, 100.0, 0.0, 0f, 0f))\n                        Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(spawned.player, otherWorld))\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"user-added material keys are accepted when the running server recognises the material\") {\n            val material = Material.matchMaterial(\"MACE\") ?: return@test\n\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(material.name to 7.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItemInMainHand(ItemStack(material))\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (7.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"main-hand attack speed follows hand swaps and uses the newly held item\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))\n                        spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)\n\n                    runSync {\n                        val swap =\n                            PlayerSwapHandItemsEvent(\n                                spawned.player,\n                                spawned.player.inventory.itemInMainHand,\n                                spawned.player.inventory.itemInOffHand,\n                            )\n                        Bukkit.getPluginManager().callEvent(swap)\n                        spawned.player.inventory.setItemInMainHand(swap.offHandItem)\n                        spawned.player.inventory.setItemInOffHand(swap.mainHandItem)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"cancelled hotbar changes keep attack speed tied to the actually held item\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n                val canceller =\n                    object : Listener {\n                        @EventHandler(priority = EventPriority.LOWEST)\n                        fun onHeld(event: PlayerItemHeldEvent) {\n                            if (event.player.uniqueId == spawned.player.uniqueId) {\n                                event.isCancelled = true\n                            }\n                        }\n                    }\n\n                try {\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(canceller, testPlugin)\n                        spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))\n                        spawned.player.inventory.setItem(1, ItemStack(Material.STICK))\n                        spawned.player.inventory.heldItemSlot = 0\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync {\n                        val event = PlayerItemHeldEvent(spawned.player, 0, 1)\n                        Bukkit.getPluginManager().callEvent(event)\n                    }\n\n                    runSync { spawned.player.inventory.heldItemSlot } shouldBe 0\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n                } finally {\n                    runSync { HandlerList.unregisterAll(canceller) }\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"cancelled hand swaps keep attack speed tied to the actual main-hand item\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n                val canceller =\n                    object : Listener {\n                        @EventHandler(priority = EventPriority.LOWEST)\n                        fun onSwap(event: PlayerSwapHandItemsEvent) {\n                            if (event.player.uniqueId == spawned.player.uniqueId) {\n                                event.isCancelled = true\n                            }\n                        }\n                    }\n\n                try {\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(canceller, testPlugin)\n                        spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n                        spawned.player.inventory.setItemInOffHand(ItemStack(Material.STICK))\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync {\n                        val event =\n                            PlayerSwapHandItemsEvent(\n                                spawned.player,\n                                spawned.player.inventory.itemInMainHand,\n                                spawned.player.inventory.itemInOffHand,\n                            )\n                        Bukkit.getPluginManager().callEvent(event)\n                    }\n\n                    runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n                } finally {\n                    runSync { HandlerList.unregisterAll(canceller) }\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"later inventory changes after a hotbar event do not trigger deferred attack-speed reconciliation\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))\n                        spawned.player.inventory.setItem(1, ItemStack(Material.STICK))\n                        spawned.player.inventory.heldItemSlot = 0\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n\n                    runSync {\n                        val event = PlayerItemHeldEvent(spawned.player, 0, 1)\n                        Bukkit.getPluginManager().callEvent(event)\n                        spawned.player.inventory.heldItemSlot = 1\n                        spawned.player.inventory.heldItemSlot = 0\n                    }\n\n                    waitForPossibleDeferredWork()\n\n                    runSync { spawned.player.inventory.heldItemSlot } shouldBe 0\n                    runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n\n        test(\"unchanged post-swap inventory keeps the swap-applied attack speed without deferred re-checking\") {\n            withAttackCooldownConfig(\n                genericAttackSpeed = 12.0,\n                heldItemAttackSpeeds = mapOf(\"IRON_SWORD\" to 19.0),\n            ) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val spawned = spawnFake(world)\n\n                try {\n                    runSync {\n                        spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))\n                        spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))\n                        fireJoin(spawned.player)\n                    }\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)\n\n                    runSync {\n                        val event =\n                            PlayerSwapHandItemsEvent(\n                                spawned.player,\n                                spawned.player.inventory.itemInMainHand,\n                                spawned.player.inventory.itemInOffHand,\n                            )\n                        Bukkit.getPluginManager().callEvent(event)\n                        spawned.player.inventory.setItemInMainHand(event.offHandItem)\n                        spawned.player.inventory.setItemInOffHand(event.mainHandItem)\n                    }\n\n                    waitForPossibleDeferredWork()\n\n                    runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD\n                    runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)\n                } finally {\n                    cleanup(spawned)\n                }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCooldownTrackerIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual\nimport io.kotest.matchers.doubles.shouldBeLessThanOrEqual\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass AttackCooldownTrackerIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n            action()\n        }).get()\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    test(\"attack cooldown tracking is only active where required\") {\n        val isModern = Reflector.versionIsNewerOrEqualTo(1, 16, 0)\n\n        val uuid = runSync {\n            val world = Bukkit.getWorld(\"world\") ?: error(\"world not loaded\")\n            val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)\n            val fp = FakePlayer(testPlugin)\n            fp.spawn(location)\n            fp.uuid\n        }\n\n        try {\n            // Let at least one tick elapse so any scheduled tracker has a chance to populate.\n            delay(2 * 50L)\n\n            val last = runSync { AttackCooldownTracker.getLastCooldown(uuid) }\n            if (isModern) {\n                last shouldBe null\n            } else {\n                val value = (last ?: error(\"Expected a cached cooldown value on legacy servers\")).toDouble()\n                value.shouldBeGreaterThanOrEqual(0.0)\n                value.shouldBeLessThanOrEqual(1.0)\n            }\n        } finally {\n            // Ensure we remove the fake player regardless of assertions.\n            runSync {\n                val player = Bukkit.getPlayer(uuid)\n                player?.let {\n                    // FakePlayer removal fires a quit event; this is enough to validate map cleanup on legacy servers.\n                    it.kickPlayer(\"test\")\n                }\n            }\n            delay(2 * 50L)\n\n            // Legacy servers: tracker should remove on quit. Modern servers: tracker should remain inactive and return null.\n            val afterQuit = runSync { AttackCooldownTracker.getLastCooldown(uuid) }\n            afterQuit shouldBe null\n        }\n    }\n})\n\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackRangeIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeLessThan\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleAttackRange\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.EntityType\nimport org.bukkit.entity.Player\nimport org.bukkit.entity.Zombie\nimport org.bukkit.event.player.PlayerDropItemEvent\nimport org.bukkit.event.player.PlayerItemHeldEvent\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n/**\n * Behavioural reach tests for the attack-range module.\n *\n * The module extends melee reach; we assert a hit just outside vanilla reach succeeds\n * when enabled, but the same swing misses when disabled. A control swing inside vanilla\n * reach must hit in both cases.\n */\n@OptIn(ExperimentalKotest::class)\nclass AttackRangeIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n        val attackRangeModule = ModuleLoader.getModules().filterIsInstance<ModuleAttackRange>().firstOrNull()\n        val applyMethod =\n            attackRangeModule?.javaClass?.getDeclaredMethod(\"applyToHeld\", Player::class.java)?.apply {\n                isAccessible = true\n            }\n\n        fun hasAttackRange(stack: ItemStack?): Boolean =\n            try {\n                if (stack == null) return false\n                val dctClass = Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\")\n                val typeField = dctClass.getField(\"ATTACK_RANGE\")\n                val type = typeField.get(null)\n                val baseTypeClass = Class.forName(\"io.papermc.paper.datacomponent.DataComponentType\")\n                val getter = ItemStack::class.java.getMethod(\"getData\", baseTypeClass)\n                getter.invoke(stack, type) != null\n            } catch (t: Throwable) {\n                false\n            }\n\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun runSync(action: () -> Unit) {\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(\n                        testPlugin,\n                        Callable {\n                            action()\n                            null\n                        },\n                    ).get()\n            }\n        }\n\n        suspend fun withModuleState(\n            enabled: Boolean,\n            maxRange: Double = 6.0,\n            margin: Double = 0.1,\n            block: suspend () -> Unit,\n        ) {\n            // Snapshot\n            val disabledOrig = ocm.config.getStringList(\"disabled_modules\").toMutableList()\n            val alwaysOrig = ocm.config.getStringList(\"always_enabled_modules\").toMutableList()\n            val maxOrig = ocm.config.getDouble(\"attack-range.max-range\")\n            val marginOrig = ocm.config.getDouble(\"attack-range.hitbox-margin\")\n\n            fun cachedSet(field: String): MutableSet<String> {\n                val f = kernitus.plugin.OldCombatMechanics.utilities.Config::class.java.getDeclaredField(field)\n                f.isAccessible = true\n                @Suppress(\"UNCHECKED_CAST\")\n                return f.get(null) as MutableSet<String>\n            }\n            val cachedDisabledOrig = cachedSet(\"disabledModules\").toSet()\n            val cachedAlwaysOrig = cachedSet(\"alwaysEnabledModules\").toSet()\n\n            try {\n                // Update lists\n                val disabled = disabledOrig.toMutableList()\n                val always = alwaysOrig.toMutableList()\n                if (enabled) {\n                    disabled.remove(\"attack-range\")\n                    if (!always.contains(\"attack-range\")) always.add(\"attack-range\")\n                } else {\n                    if (!disabled.contains(\"attack-range\")) disabled.add(\"attack-range\")\n                    always.remove(\"attack-range\")\n                }\n                ocm.config.set(\"disabled_modules\", disabled)\n                ocm.config.set(\"always_enabled_modules\", always)\n\n                cachedSet(\"disabledModules\").apply {\n                    clear()\n                    addAll(disabled.map { it.lowercase() })\n                }\n                cachedSet(\"alwaysEnabledModules\").apply {\n                    clear()\n                    addAll(always.map { it.lowercase() })\n                }\n\n                // Config tweaks\n                ocm.config.set(\"attack-range.max-range\", maxRange)\n                ocm.config.set(\"attack-range.hitbox-margin\", margin)\n\n                attackRangeModule?.reload()\n                ModuleLoader.toggleModules()\n\n                block()\n            } finally {\n                // Restore\n                ocm.config.set(\"disabled_modules\", disabledOrig)\n                ocm.config.set(\"always_enabled_modules\", alwaysOrig)\n                ocm.config.set(\"attack-range.max-range\", maxOrig)\n                ocm.config.set(\"attack-range.hitbox-margin\", marginOrig)\n\n                cachedSet(\"disabledModules\").apply {\n                    clear()\n                    addAll(cachedDisabledOrig)\n                }\n                cachedSet(\"alwaysEnabledModules\").apply {\n                    clear()\n                    addAll(cachedAlwaysOrig)\n                }\n\n                attackRangeModule?.reload()\n                ModuleLoader.toggleModules()\n            }\n        }\n\n        data class Actors(\n            val fake: FakePlayer,\n            val player: Player,\n            val zombie: Zombie,\n        )\n\n        fun spawnActors(): Actors {\n            lateinit var fake: FakePlayer\n            lateinit var player: Player\n            lateinit var zombie: Zombie\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                fake = FakePlayer(testPlugin)\n                fake.spawn(Location(world, 0.0, 100.0, 0.0))\n                player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n                player.gameMode = GameMode.SURVIVAL\n                player.isInvulnerable = false\n                player.inventory.clear()\n                player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n\n                zombie = world.spawnEntity(Location(world, 0.0, 100.0, 0.0), EntityType.ZOMBIE) as Zombie\n                zombie.health = zombie.maxHealth\n            }\n            return Actors(fake, player, zombie)\n        }\n\n        fun cleanup(actors: Actors) {\n            runSync {\n                actors.zombie.remove()\n            }\n            runSync { actors.fake.removePlayer() }\n        }\n\n        fun faceEntity(\n            player: Player,\n            target: org.bukkit.entity.Entity,\n        ) {\n            val eye = player.eyeLocation\n            val tgt = target.location.clone().add(0.0, target.height / 2.0, 0.0)\n            val dir = tgt.toVector().subtract(eye.toVector())\n            val yaw = Math.toDegrees(Math.atan2(-dir.x, dir.z)).toFloat()\n            val pitch = Math.toDegrees(-Math.atan2(dir.y, Math.hypot(dir.x, dir.z))).toFloat()\n            val newLoc = player.location.clone()\n            newLoc.yaw = yaw\n            newLoc.pitch = pitch\n            player.teleport(newLoc)\n        }\n\n        fun swingAt(\n            zombie: Zombie,\n            player: Player,\n        ) {\n            runSync {\n                faceEntity(player, zombie)\n                player.attack(zombie)\n            }\n        }\n\n        test(\"extended reach hits just outside vanilla range when module enabled (Paper 1.21.11+)\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test\n            if (attackRangeModule == null) return@test\n\n            withModuleState(enabled = true, maxRange = 5.0, margin = 0.1) {\n                val actors = spawnActors()\n                val (_, player, zombie) = actors\n                runSync { applyMethod?.invoke(attackRangeModule, player) }\n\n                runSync {\n                    zombie.noDamageTicks = 0\n                    zombie.health = zombie.maxHealth\n                    zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))\n                }\n\n                var startHealth = zombie.health\n                swingAt(zombie, player)\n\n                runSync { zombie.health shouldBeLessThan startHealth }\n\n                cleanup(actors)\n            }\n        }\n\n        test(\"reach boost is removed after disabling while holding the same item (Paper 1.21.11+)\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test\n            if (attackRangeModule == null) return@test\n\n            val actors = spawnActors()\n            val (_, player, zombie) = actors\n\n            withModuleState(enabled = true, maxRange = 5.5) {\n                runSync { applyMethod?.invoke(attackRangeModule, player) }\n                runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }\n                var start = zombie.health\n                swingAt(zombie, player) // should hit with extended reach\n                runSync { zombie.health shouldBeLessThan start }\n\n                withModuleState(enabled = false) {\n                    runSync {\n                        // trigger hand re-evaluation\n                        Bukkit.getPluginManager().callEvent(\n                            PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),\n                        )\n                        zombie.health = zombie.maxHealth\n                        zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))\n                    }\n                    start = zombie.health\n                    swingAt(zombie, player) // should now miss\n                    runSync { zombie.health shouldBe start }\n                }\n            }\n\n            cleanup(actors)\n        }\n\n        test(\"dropped items do not retain reach boost (Paper 1.21.11+)\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test\n            if (attackRangeModule == null) return@test\n\n            val actors = spawnActors()\n            val (_, player, zombie) = actors\n\n            withModuleState(enabled = true) {\n                runSync { applyMethod?.invoke(attackRangeModule, player) }\n                runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }\n                var start = zombie.health\n                swingAt(zombie, player) // extended reach hit\n                runSync { zombie.health shouldBeLessThan start }\n\n                val dropped: ItemStack =\n                    run {\n                        var item: ItemStack? = null\n                        runSync {\n                            val drop = player.world.dropItem(player.location, player.inventory.itemInMainHand.clone())\n                            Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop))\n                            item = drop.itemStack\n                            drop.remove()\n                        }\n                        item!!\n                    }\n\n                withModuleState(enabled = false) {\n                    runSync {\n                        player.inventory.setItemInMainHand(dropped)\n                        Bukkit.getPluginManager().callEvent(\n                            PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),\n                        )\n                        zombie.health = zombie.maxHealth\n                        zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))\n                    }\n                    start = zombie.health\n                    swingAt(zombie, player) // should miss because drop cleaned component\n                    runSync { zombie.health shouldBe start }\n                }\n            }\n\n            cleanup(actors)\n        }\n\n        test(\"extended reach does not apply when module disabled; close swing still hits (Paper 1.21.11+)\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test\n            if (attackRangeModule == null) return@test\n\n            withModuleState(enabled = false) {\n                val actors = spawnActors()\n                val (_, player, zombie) = actors\n\n                // Far swing should miss when module disabled\n                runSync {\n                    zombie.noDamageTicks = 0\n                    zombie.health = zombie.maxHealth\n                    zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))\n                }\n                var healthAfterFar = zombie.health\n                swingAt(zombie, player)\n                runSync { healthAfterFar = zombie.health }\n\n                // Control: move inside vanilla reach and ensure it hits\n                runSync {\n                    zombie.noDamageTicks = 0\n                    zombie.teleport(player.location.clone().add(0.0, 0.0, 2.5))\n                }\n                swingAt(zombie, player)\n\n                runSync {\n                    zombie.health shouldBeLessThan healthAfterFar\n                }\n\n                cleanup(actors)\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttributeModifierCompat.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.attribute.Attribute\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.inventory.meta.ItemMeta\nimport com.google.common.collect.HashMultimap\nimport com.google.common.collect.Multimap\nimport java.util.UUID\n\nfun createAttributeModifier(\n    name: String,\n    amount: Double,\n    operation: AttributeModifier.Operation,\n    slot: EquipmentSlot? = null,\n    uuid: UUID = UUID.randomUUID()\n): AttributeModifier {\n    // Use the most specific constructor available at runtime.\n    if (slot != null) {\n        try {\n            @Suppress(\"DEPRECATION\")\n            return AttributeModifier(uuid, name, amount, operation, slot)\n        } catch (e: NoSuchMethodError) {\n            // Fall back to the older signatures below.\n        }\n    }\n\n    try {\n        @Suppress(\"DEPRECATION\")\n        return AttributeModifier(uuid, name, amount, operation)\n    } catch (e: NoSuchMethodError) {\n        @Suppress(\"DEPRECATION\")\n        return AttributeModifier(name, amount, operation)\n    }\n}\n\nfun addAttributeModifierCompat(meta: ItemMeta, attribute: Attribute, modifier: AttributeModifier) {\n    try {\n        meta.addAttributeModifier(attribute, modifier)\n        return\n    } catch (e: NoSuchMethodError) {\n        // Older APIs do not expose addAttributeModifier on ItemMeta.\n    }\n\n    try {\n        val multimap = HashMultimap.create<Attribute, AttributeModifier>()\n        multimap.put(attribute, modifier)\n        meta.setAttributeModifiers(multimap)\n    } catch (e: NoSuchMethodError) {\n        // Attribute modifiers are not supported on this API version.\n    }\n}\n\nfun getDefaultAttributeModifiersCompat(\n    item: ItemStack,\n    slot: EquipmentSlot,\n    attribute: Attribute\n): Collection<AttributeModifier> {\n    try {\n        return item.type.getDefaultAttributeModifiers(slot)[attribute] ?: emptySet()\n    } catch (e: NoSuchMethodError) {\n        // Fall back to older Material APIs if present.\n    }\n\n    val modifiers = try {\n        val method = item.type.javaClass.getMethod(\"getAttributeModifiers\", EquipmentSlot::class.java)\n        @Suppress(\"UNCHECKED_CAST\")\n        val multimap = method.invoke(item.type, slot) as Multimap<Attribute, AttributeModifier>\n        multimap.get(attribute) ?: emptySet()\n    } catch (e: Exception) {\n        emptySet()\n    }\n\n    if (modifiers.isNotEmpty()) {\n        return modifiers\n    }\n\n    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n    if (attackDamageAttribute != null && attribute == attackDamageAttribute && slot == EquipmentSlot.HAND) {\n        val fallbackDamage = NewWeaponDamage.getDamageOrNull(item.type) ?: return emptySet()\n        val amount = fallbackDamage.toDouble() - 1.0\n        val fallbackModifier = createAttributeModifier(\n            name = \"ocm-fallback-damage\",\n            amount = amount,\n            operation = AttributeModifier.Operation.ADD_NUMBER,\n            slot = slot\n        )\n        return setOf(fallbackModifier)\n    }\n\n    return emptySet()\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ChorusFruitIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.booleans.shouldBeTrue\nimport io.kotest.matchers.doubles.shouldBeLessThanOrEqual\nimport kernitus.plugin.OldCombatMechanics.module.ModuleChorusFruit\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.block.BlockFace\nimport org.bukkit.entity.Player\nimport org.bukkit.event.player.PlayerTeleportEvent\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass ChorusFruitIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val chorusModule = ModuleLoader.getModules()\n        .filterIsInstance<ModuleChorusFruit>()\n        .firstOrNull() ?: error(\"ModuleChorusFruit not registered\")\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun preparePlatform() {\n        runSync {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            // Solid floor at y=100 and clear air above it in a 25x25 area around the origin\n            for (x in -12..12) {\n                for (z in -12..12) {\n                    world.getBlockAt(x, 100, z).type = Material.STONE\n                    for (y in 101..105) {\n                        world.getBlockAt(x, y, z).type = Material.AIR\n                    }\n                }\n            }\n        }\n    }\n\n    fun clearPlatform() {\n        runSync {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            for (x in -12..12) {\n                for (z in -12..12) {\n                    for (y in 99..105) {\n                        world.getBlockAt(x, y, z).type = Material.AIR\n                    }\n                }\n            }\n        }\n    }\n\n    suspend fun TestScope.withChorusConfig(distance: Double, block: suspend TestScope.() -> Unit) {\n        val enabled = ocm.config.getBoolean(\"chorus-fruit.enabled\")\n        val maxDistance = ocm.config.getDouble(\"chorus-fruit.max-teleportation-distance\")\n        val preventEating = ocm.config.getBoolean(\"chorus-fruit.prevent-eating\")\n        val hungerValue = ocm.config.getInt(\"chorus-fruit.hunger-value\")\n        val saturationValue = ocm.config.getDouble(\"chorus-fruit.saturation-value\")\n\n        try {\n            ocm.config.set(\"chorus-fruit.enabled\", true)\n            ocm.config.set(\"chorus-fruit.max-teleportation-distance\", distance)\n            ocm.config.set(\"chorus-fruit.prevent-eating\", false)\n            ocm.config.set(\"chorus-fruit.hunger-value\", hungerValue)\n            ocm.config.set(\"chorus-fruit.saturation-value\", saturationValue)\n            chorusModule.reload()\n            ModuleLoader.toggleModules()\n            block()\n        } finally {\n            clearPlatform()\n            ocm.config.set(\"chorus-fruit.enabled\", enabled)\n            ocm.config.set(\"chorus-fruit.max-teleportation-distance\", maxDistance)\n            ocm.config.set(\"chorus-fruit.prevent-eating\", preventEating)\n            ocm.config.set(\"chorus-fruit.hunger-value\", hungerValue)\n            ocm.config.set(\"chorus-fruit.saturation-value\", saturationValue)\n            chorusModule.reload()\n            ModuleLoader.toggleModules()\n        }\n    }\n\n    fun Location.isSafe(): Boolean {\n        val feet = block\n        val head = feet.getRelative(BlockFace.UP)\n        val below = feet.getRelative(BlockFace.DOWN)\n        val legacy = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.versionIsNewerOrEqualTo(1, 13, 0)\n        val feetPassable = if (legacy) !feet.type.isSolid else feet.isPassable\n        val headPassable = if (legacy) !head.type.isSolid else head.isPassable\n        return feetPassable && headPassable && below.type.isSolid\n    }\n\n    suspend fun withFakePlayer(origin: Location, block: suspend (Player) -> Unit) {\n        lateinit var fake: FakePlayer\n        lateinit var player: Player\n\n        runSync {\n            fake = FakePlayer(testPlugin)\n            fake.spawn(origin)\n            player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n            player.gameMode = GameMode.SURVIVAL\n            player.isInvulnerable = false\n            player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n            val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n            data.setModesetForWorld(player.world.uid, \"old\")\n            kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)\n        }\n\n        try {\n            block(player)\n        } finally {\n            runSync {\n                fake.removePlayer()\n            }\n        }\n    }\n\n    test(\"chorus fruit custom distance teleports to a safe spot\") {\n        preparePlatform()\n        withChorusConfig(distance = 1.0) {\n            val origin = Location(checkNotNull(Bukkit.getWorld(\"world\")), 0.5, 101.0, 0.5)\n            withFakePlayer(origin) { player ->\n                var result: Location? = null\n                runSync {\n                    val event = PlayerTeleportEvent(\n                        player,\n                        player.location,\n                        player.location.clone(),\n                        PlayerTeleportEvent.TeleportCause.CHORUS_FRUIT\n                    )\n                    Bukkit.getPluginManager().callEvent(event)\n                    result = event.to\n                }\n\n                val target = result ?: error(\"Teleport target was null\")\n                // Horizontal displacement should respect the configured radius\n                kotlin.math.abs(target.x - origin.x) shouldBeLessThanOrEqual 1.0\n                kotlin.math.abs(target.z - origin.z) shouldBeLessThanOrEqual 1.0\n                // Y stays within the search band (clamped to world height)\n                kotlin.math.abs(target.y - origin.y) shouldBeLessThanOrEqual 1.0\n                target.isSafe().shouldBeTrue()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ConfigMigrationIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.collections.shouldContain\nimport io.kotest.matchers.collections.shouldNotContain\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport org.bukkit.Bukkit\nimport org.bukkit.configuration.file.YamlConfiguration\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.io.File\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass ConfigMigrationIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun withConfigFile(block: () -> Unit) {\n        val dataFolder = ocm.dataFolder\n        val configFile = File(dataFolder, \"config.yml\")\n        val backupFile = File(dataFolder, \"config-backup.yml\")\n        val originalConfig = if (configFile.exists()) configFile.readText() else \"\"\n        val hadBackup = backupFile.exists()\n        val originalBackup = if (hadBackup) backupFile.readText() else null\n\n        try {\n            block()\n        } finally {\n            if (!configFile.parentFile.exists()) {\n                configFile.parentFile.mkdirs()\n            }\n            configFile.writeText(originalConfig)\n            if (hadBackup) {\n                backupFile.writeText(originalBackup ?: \"\")\n            } else if (backupFile.exists()) {\n                backupFile.delete()\n            }\n            ocm.reloadConfig()\n            Config.reload()\n        }\n    }\n\n    test(\"config upgrade migrates module buckets and preserves modesets\") {\n        runSync {\n            withConfigFile {\n                val configFile = File(ocm.dataFolder, \"config.yml\")\n                val oldConfig = YamlConfiguration.loadConfiguration(configFile)\n                val currentVersion = oldConfig.getInt(\"config-version\")\n                val oldVersion = currentVersion - 1\n\n                oldConfig.set(\"config-version\", oldVersion)\n                oldConfig.set(\"force-below-1-18-1-config-upgrade\", true)\n\n                oldConfig.set(\n                    \"modesets\",\n                    mapOf(\n                        \"custom\" to listOf(\"disable-offhand\"),\n                        \"alt\" to listOf(\"old-golden-apples\")\n                    )\n                )\n                oldConfig.set(\"worlds.world\", listOf(\"custom\", \"alt\"))\n\n                oldConfig.set(\"disable-offhand.enabled\", false)\n                oldConfig.set(\"old-golden-apples.enabled\", true)\n                oldConfig.set(\"old-potion-effects.enabled\", true)\n                oldConfig.set(\"disable-attack-cooldown.enabled\", false)\n\n                oldConfig.save(configFile)\n\n                Config.reload()\n\n                val upgradedConfig = ocm.config\n                upgradedConfig.getInt(\"config-version\") shouldBe currentVersion\n\n                val alwaysEnabled = upgradedConfig.getStringList(\"always_enabled_modules\")\n                val disabledModules = upgradedConfig.getStringList(\"disabled_modules\")\n\n                alwaysEnabled.shouldContain(\"old-potion-effects\")\n                disabledModules.shouldContain(\"disable-offhand\")\n                disabledModules.shouldContain(\"disable-attack-cooldown\")\n                disabledModules.shouldNotContain(\"old-golden-apples\")\n\n                val modesetsSection = upgradedConfig.getConfigurationSection(\"modesets\")\n                    ?: error(\"Modesets section missing after migration\")\n                modesetsSection.getKeys(false).shouldContain(\"custom\")\n                modesetsSection.getKeys(false).shouldContain(\"alt\")\n\n                upgradedConfig.getStringList(\"modesets.custom\").shouldNotContain(\"disable-offhand\")\n                upgradedConfig.getStringList(\"modesets.alt\").shouldContain(\"old-golden-apples\")\n                upgradedConfig.getStringList(\"worlds.world\") shouldBe listOf(\"custom\", \"alt\")\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ConsumableComponentIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.block.BlockFace\nimport org.bukkit.entity.Player\nimport org.bukkit.event.block.Action\nimport org.bukkit.event.entity.PlayerDeathEvent\nimport org.bukkit.event.inventory.ClickType\nimport org.bukkit.event.inventory.InventoryAction\nimport org.bukkit.event.inventory.InventoryClickEvent\nimport org.bukkit.event.inventory.InventoryDragEvent\nimport org.bukkit.event.inventory.InventoryType\nimport org.bukkit.event.player.PlayerChangedWorldEvent\nimport org.bukkit.event.player.PlayerDropItemEvent\nimport org.bukkit.event.player.PlayerInteractEvent\nimport org.bukkit.event.player.PlayerItemHeldEvent\nimport org.bukkit.event.player.PlayerJoinEvent\nimport org.bukkit.event.player.PlayerQuitEvent\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.Optional\nimport java.util.UUID\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass ConsumableComponentIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n        val swordBlocking =\n            ModuleLoader.getModules().filterIsInstance<ModuleSwordBlocking>().firstOrNull()\n                ?: error(\"ModuleSwordBlocking not registered\")\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun <T> runSync(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(testPlugin, Callable { action() })\n                    .get()\n            }\n\n        suspend fun delayTicks(ticks: Long) {\n            delay(ticks * 50L)\n        }\n\n        fun rightClickMainHand(player: Player) {\n            runSync {\n                val event =\n                    PlayerInteractEvent(\n                        player,\n                        Action.RIGHT_CLICK_AIR,\n                        player.inventory.itemInMainHand,\n                        null,\n                        BlockFace.SELF,\n                        EquipmentSlot.HAND,\n                    )\n                Bukkit.getPluginManager().callEvent(event)\n            }\n        }\n\n        fun paperDataComponentApiPresent(): Boolean =\n            try {\n                Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\")\n                true\n            } catch (_: Throwable) {\n                false\n            }\n\n        fun paperConsumablePathAvailable(): Boolean {\n            if (!paperDataComponentApiPresent()) return false\n            val module = ModuleLoader.getModules().filterIsInstance<ModuleSwordBlocking>().firstOrNull() ?: return false\n            return try {\n                val supportedField = ModuleSwordBlocking::class.java.getDeclaredField(\"paperSupported\")\n                supportedField.isAccessible = true\n                val adapterField = ModuleSwordBlocking::class.java.getDeclaredField(\"paperAdapter\")\n                adapterField.isAccessible = true\n                supportedField.getBoolean(module) && adapterField.get(module) != null\n            } catch (_: Throwable) {\n                false\n            }\n        }\n\n        fun packetEventsClass(name: String): Class<*> = Class.forName(name, true, ocm.javaClass.classLoader)\n\n        fun packetEventsClientVersionClass(): Class<*> =\n            packetEventsClass(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.ClientVersion\")\n\n        fun packetEventsUserClass(): Class<*> =\n            packetEventsClass(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.User\")\n\n        fun packetEventsUserProfileClass(): Class<*> =\n            packetEventsClass(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.UserProfile\")\n\n        fun packetEventsConnectionStateClass(): Class<*> =\n            packetEventsClass(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.ConnectionState\")\n\n        fun packetEventsApi(): Any {\n            val packetEventsClass =\n                packetEventsClass(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.PacketEvents\")\n            return packetEventsClass.getMethod(\"getAPI\").invoke(null)\n                ?: error(\"PacketEvents API not available\")\n        }\n\n        fun packetEventsPlayerManager(): Any {\n            val api = packetEventsApi()\n            val method = api.javaClass.getDeclaredMethod(\"getPlayerManager\")\n            method.isAccessible = true\n            return method.invoke(api)\n                ?: error(\"PacketEvents PlayerManager not available\")\n        }\n\n        fun packetEventsProtocolManager(): Any {\n            val api = packetEventsApi()\n            val method = api.javaClass.getDeclaredMethod(\"getProtocolManager\")\n            method.isAccessible = true\n            return method.invoke(api)\n                ?: error(\"PacketEvents ProtocolManager not available\")\n        }\n\n        suspend fun requirePacketEventsUser(player: Player): Any {\n            val playerManager = packetEventsPlayerManager()\n            val getUserMethod = playerManager.javaClass.getDeclaredMethod(\"getUser\", Any::class.java)\n            getUserMethod.isAccessible = true\n            repeat(10) {\n                val user = runSync { getUserMethod.invoke(playerManager, player) }\n                if (user != null) return user\n                delayTicks(1)\n            }\n            val getChannelMethod = playerManager.javaClass.getDeclaredMethod(\"getChannel\", Any::class.java)\n            getChannelMethod.isAccessible = true\n            val channel =\n                runSync { getChannelMethod.invoke(playerManager, player) }\n                    ?: error(\"PacketEvents channel missing for ${player.name}\")\n            val protocolManager = packetEventsProtocolManager()\n            val setChannel = protocolManager.javaClass.getMethod(\"setChannel\", UUID::class.java, Any::class.java)\n            runSync { setChannel.invoke(protocolManager, player.uniqueId, channel) }\n\n            val connectionStateClass = packetEventsConnectionStateClass()\n\n            @Suppress(\"UNCHECKED_CAST\")\n            val connectionStateEnum = connectionStateClass as Class<out Enum<*>>\n            val playState = java.lang.Enum.valueOf(connectionStateEnum, \"PLAY\")\n\n            val profileClass = packetEventsUserProfileClass()\n            val profile =\n                profileClass\n                    .getConstructor(UUID::class.java, String::class.java)\n                    .newInstance(player.uniqueId, player.name)\n\n            val clientVersionClass = packetEventsClientVersionClass()\n\n            @Suppress(\"UNCHECKED_CAST\")\n            val clientVersionEnum = clientVersionClass as Class<out Enum<*>>\n            val defaultVersion = java.lang.Enum.valueOf(clientVersionEnum, \"V_1_21_11\")\n\n            val userClass = packetEventsUserClass()\n            val user =\n                userClass\n                    .getConstructor(Any::class.java, connectionStateClass, clientVersionClass, profileClass)\n                    .newInstance(channel, playState, defaultVersion, profile)\n\n            val setUser = protocolManager.javaClass.getMethod(\"setUser\", Any::class.java, userClass)\n            runSync { setUser.invoke(protocolManager, channel, user) }\n            return user\n        }\n\n        fun packetEventsClientVersion(versionName: String): Any {\n            val versionClass = packetEventsClientVersionClass()\n\n            @Suppress(\"UNCHECKED_CAST\")\n            val enumClass = versionClass as Class<out Enum<*>>\n            return java.lang.Enum.valueOf(enumClass, versionName)\n        }\n\n        fun unknownPacketEventsClientVersionName(): String? {\n            val versionClass = packetEventsClientVersionClass()\n\n            @Suppress(\"UNCHECKED_CAST\")\n            val enumClass = versionClass as Class<out Enum<*>>\n            val names = enumClass.enumConstants.map { it.name }\n            return when {\n                names.contains(\"UNKNOWN\") -> \"UNKNOWN\"\n                names.contains(\"HIGHER_THAN_SUPPORTED_VERSIONS\") -> \"HIGHER_THAN_SUPPORTED_VERSIONS\"\n                else -> null\n            }\n        }\n\n        suspend fun withPacketEventsClientVersion(\n            player: Player,\n            versionName: String,\n            block: suspend () -> Unit,\n        ) {\n            val user = requirePacketEventsUser(player)\n            val versionClass = packetEventsClientVersionClass()\n            val getVersion = user.javaClass.getDeclaredMethod(\"getClientVersion\")\n            val setVersion = user.javaClass.getDeclaredMethod(\"setClientVersion\", versionClass)\n            getVersion.isAccessible = true\n            setVersion.isAccessible = true\n            val original = runSync { getVersion.invoke(user) }\n            val target = packetEventsClientVersion(versionName)\n            runSync { setVersion.invoke(user, target) }\n            try {\n                block()\n            } finally {\n                if (original != null) {\n                    runSync { setVersion.invoke(user, original) }\n                }\n            }\n        }\n\n        fun nmsItemStack(stack: ItemStack?): Any? {\n            if (stack == null) return null\n            var handle: Any? = null\n            try {\n                var type: Class<*>? = stack.javaClass\n                while (type != null && type != Any::class.java) {\n                    val field =\n                        try {\n                            type.getDeclaredField(\"handle\")\n                        } catch (_: NoSuchFieldException) {\n                            type = type.superclass\n                            continue\n                        }\n                    field.isAccessible = true\n                    handle = runCatching { field.get(stack) }.getOrNull()\n                    break\n                }\n            } catch (_: Throwable) {\n                handle = null\n            }\n            if (handle != null) return handle\n            return try {\n                val craftItemStack = Class.forName(\"org.bukkit.craftbukkit.inventory.CraftItemStack\")\n                val asNmsCopy = craftItemStack.getMethod(\"asNMSCopy\", ItemStack::class.java)\n                asNmsCopy.invoke(null, stack)\n            } catch (t: Throwable) {\n                throw IllegalStateException(\n                    \"Failed to obtain NMS ItemStack (${t::class.java.simpleName}: ${t.message})\",\n                    t,\n                )\n            }\n        }\n\n        fun craftMirrorStack(type: Material): ItemStack {\n            val craftItemStack = Class.forName(\"org.bukkit.craftbukkit.inventory.CraftItemStack\")\n            val nmsItemStackClass = Class.forName(\"net.minecraft.world.item.ItemStack\")\n            val asNmsCopy = craftItemStack.getMethod(\"asNMSCopy\", ItemStack::class.java)\n            val asCraftMirror = craftItemStack.getMethod(\"asCraftMirror\", nmsItemStackClass)\n            val nms = asNmsCopy.invoke(null, ItemStack(type))\n            return asCraftMirror.invoke(null, nms) as ItemStack\n        }\n\n        fun consumablePatchEntry(stack: ItemStack?): Optional<*>? {\n            val nmsStack = nmsItemStack(stack) ?: return null\n            return try {\n                val patch = nmsStack.javaClass.getMethod(\"getComponentsPatch\").invoke(nmsStack) ?: return null\n                val dataComponentType = Class.forName(\"net.minecraft.core.component.DataComponentType\")\n                val dataComponents = Class.forName(\"net.minecraft.core.component.DataComponents\")\n                val consumableType = dataComponents.getField(\"CONSUMABLE\").get(null)\n                val getMethod = patch.javaClass.getMethod(\"get\", dataComponentType)\n                getMethod.invoke(patch, consumableType) as? Optional<*>\n            } catch (t: Throwable) {\n                throw IllegalStateException(\n                    \"Failed to inspect data component patch (${t::class.java.simpleName}: ${t.message})\",\n                    t,\n                )\n            }\n        }\n\n        fun hasConsumableRemoval(stack: ItemStack?): Boolean {\n            val entry = consumablePatchEntry(stack) ?: return false\n            return !entry.isPresent\n        }\n\n        fun nmsConsumableType(): Any = Class.forName(\"net.minecraft.core.component.DataComponents\").getField(\"CONSUMABLE\").get(null)\n\n        fun nmsConsumableComponent(): Any {\n            val nmsConsumable = Class.forName(\"net.minecraft.world.item.component.Consumable\")\n            val builder = nmsConsumable.getMethod(\"builder\").invoke(null)\n            val nmsUseAnim = Class.forName(\"net.minecraft.world.item.ItemUseAnimation\")\n            val blockAnim = nmsUseAnim.getField(\"BLOCK\").get(null)\n            val withSeconds = builder.javaClass.getMethod(\"consumeSeconds\", Float::class.javaPrimitiveType).invoke(builder, 1.6f)\n            val withAnim = withSeconds.javaClass.getMethod(\"animation\", nmsUseAnim).invoke(withSeconds, blockAnim)\n            return withAnim.javaClass.getMethod(\"build\").invoke(withAnim)\n        }\n\n        fun hasConsumableComponent(stack: ItemStack?): Boolean {\n            val nmsStack = nmsItemStack(stack) ?: return false\n            return try {\n                val dataComponentType = Class.forName(\"net.minecraft.core.component.DataComponentType\")\n                val consumableType = nmsConsumableType()\n                val hasMethod = nmsStack.javaClass.getMethod(\"has\", dataComponentType)\n                val result = hasMethod.invoke(nmsStack, consumableType)\n                result is Boolean && result\n            } catch (_: Throwable) {\n                false\n            }\n        }\n\n        fun applyConsumableComponent(stack: ItemStack?) {\n            val nmsStack = nmsItemStack(stack) ?: return\n            try {\n                val dataComponentType = Class.forName(\"net.minecraft.core.component.DataComponentType\")\n                val consumableType = nmsConsumableType()\n                val consumableComponent = nmsConsumableComponent()\n                val setMethod =\n                    nmsStack.javaClass.methods.firstOrNull { m ->\n                        m.name == \"set\" &&\n                            m.parameterCount == 2 &&\n                            m.parameterTypes[0] == dataComponentType\n                    } ?: error(\"NMS ItemStack#set(DataComponentType, value) not found\")\n                setMethod.invoke(nmsStack, consumableType, consumableComponent)\n            } catch (t: Throwable) {\n                throw IllegalStateException(\n                    \"Failed to apply NMS consumable component (${t::class.java.simpleName}: ${t.message})\",\n                    t,\n                )\n            }\n        }\n\n        fun assertNoConsumableRemoval(\n            stack: ItemStack?,\n            label: String,\n        ) {\n            val entry = consumablePatchEntry(stack)\n            if (entry != null && !entry.isPresent) {\n                error(\"$label gained !minecraft:consumable\")\n            }\n        }\n\n        fun setModeset(\n            player: Player,\n            modeset: String?,\n        ) {\n            val data = getPlayerData(player.uniqueId)\n            val worldId = player.world.uid\n            if (modeset == null) {\n                data.modesetByWorld.remove(worldId)\n            } else {\n                data.setModesetForWorld(worldId, modeset)\n            }\n            setPlayerData(player.uniqueId, data)\n        }\n\n        fun syntheticPlayerDeathEvent(\n            player: Player,\n            drops: MutableList<ItemStack>,\n        ): PlayerDeathEvent {\n            for (ctor in PlayerDeathEvent::class.java.constructors) {\n                val args = arrayOfNulls<Any>(ctor.parameterCount)\n                var supported = true\n                for ((index, paramType) in ctor.parameterTypes.withIndex()) {\n                    val value: Any? =\n                        when {\n                            Player::class.java.isAssignableFrom(paramType) -> {\n                                player\n                            }\n\n                            MutableList::class.java.isAssignableFrom(paramType) || List::class.java.isAssignableFrom(paramType) -> {\n                                drops\n                            }\n\n                            paramType == Int::class.javaPrimitiveType || paramType == Int::class.java -> {\n                                0\n                            }\n\n                            paramType == Boolean::class.javaPrimitiveType || paramType == Boolean::class.java -> {\n                                false\n                            }\n\n                            paramType == String::class.java -> {\n                                \"\"\n                            }\n\n                            else -> {\n                                if (paramType.isPrimitive) {\n                                    supported = false\n                                    null\n                                } else {\n                                    null\n                                }\n                            }\n                        }\n                    if (!supported) break\n                    args[index] = value\n                }\n                if (!supported) continue\n                val instance = runCatching { ctor.newInstance(*args) }.getOrNull() ?: continue\n                if (instance is PlayerDeathEvent) {\n                    return instance\n                }\n            }\n            error(\"Failed to create PlayerDeathEvent reflectively\")\n        }\n\n        fun snapshotSection(path: String): Any? {\n            val section = ocm.config.getConfigurationSection(path)\n            return section?.getValues(false) ?: ocm.config.get(path)\n        }\n\n        fun restoreSection(\n            path: String,\n            value: Any?,\n        ) {\n            ocm.config.set(path, null)\n            when (value) {\n                null -> {\n                    Unit\n                }\n\n                is Map<*, *> -> {\n                    @Suppress(\"UNCHECKED_CAST\")\n                    ocm.config.createSection(path, value as Map<String, Any?>)\n                }\n\n                else -> {\n                    ocm.config.set(path, value)\n                }\n            }\n        }\n\n        suspend fun withWorldModesets(\n            worldModesets: List<String>,\n            block: suspend () -> Unit,\n        ) {\n            val originalWorlds = runSync { snapshotSection(\"worlds\") }\n            try {\n                runSync {\n                    ocm.config.set(\"worlds.world\", worldModesets)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n                block()\n            } finally {\n                runSync {\n                    restoreSection(\"worlds\", originalWorlds)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n            }\n        }\n\n        suspend fun withSwordBlockingDisabled(block: suspend () -> Unit) {\n            val originalAlways = runSync { snapshotSection(\"always_enabled_modules\") }\n            val originalDisabled = runSync { snapshotSection(\"disabled_modules\") }\n            val originalModesets = runSync { snapshotSection(\"modesets\") }\n            try {\n                runSync {\n                    val always =\n                        ocm\n                            .config\n                            .getStringList(\"always_enabled_modules\")\n                            .filterNot { it.equals(\"sword-blocking\", ignoreCase = true) }\n                    ocm.config.set(\"always_enabled_modules\", always)\n\n                    val disabled =\n                        ocm\n                            .config\n                            .getStringList(\"disabled_modules\")\n                            .filterNot { it.equals(\"sword-blocking\", ignoreCase = true) }\n                            .toMutableList()\n                    disabled.add(\"sword-blocking\")\n                    ocm.config.set(\"disabled_modules\", disabled)\n\n                    val modesetsSection =\n                        ocm.config.getConfigurationSection(\"modesets\")\n                            ?: error(\"modesets missing\")\n                    for (key in modesetsSection.getKeys(false)) {\n                        val modules =\n                            modesetsSection\n                                .getStringList(key)\n                                .filterNot { it.equals(\"sword-blocking\", ignoreCase = true) }\n                        ocm.config.set(\"modesets.$key\", modules)\n                    }\n\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n                block()\n            } finally {\n                runSync {\n                    restoreSection(\"always_enabled_modules\", originalAlways)\n                    restoreSection(\"disabled_modules\", originalDisabled)\n                    restoreSection(\"modesets\", originalModesets)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n            }\n        }\n\n        suspend fun withSwordBlockingPaperAnimation(\n            enabled: Boolean,\n            block: suspend () -> Unit,\n        ) {\n            val original = runSync { snapshotSection(\"sword-blocking.paper-animation\") }\n            try {\n                runSync {\n                    ocm.config.set(\"sword-blocking.paper-animation\", enabled)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n                block()\n            } finally {\n                runSync {\n                    restoreSection(\"sword-blocking.paper-animation\", original)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n            }\n        }\n\n        lateinit var fake: FakePlayer\n        lateinit var player: Player\n\n        beforeSpec {\n            runSync {\n                val world = Bukkit.getWorld(\"world\") ?: error(\"world missing\")\n                fake = FakePlayer(testPlugin)\n                fake.spawn(Location(world, 0.0, 100.0, 0.0))\n                player = Bukkit.getPlayer(fake.uuid) ?: error(\"player missing\")\n                player.gameMode = GameMode.SURVIVAL\n                player.isInvulnerable = false\n                player.inventory.clear()\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                player.updateInventory()\n            }\n        }\n\n        afterSpec {\n            runSync {\n                fake.removePlayer()\n            }\n        }\n\n        beforeTest {\n            runSync {\n                setModeset(player, \"old\")\n                player.inventory.clear()\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                player.setItemOnCursor(ItemStack(Material.AIR))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n        }\n\n        test(\"hotbar swap keeps food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                player.inventory.setItem(0, ItemStack(Material.BREAD))\n                player.inventory.setItem(1, ItemStack(Material.STONE))\n                player.inventory.heldItemSlot = 0\n            }\n\n            val before = runSync { player.inventory.getItem(0) }\n            assertNoConsumableRemoval(before, \"hotbar food (before)\")\n\n            runSync {\n                Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1))\n            }\n\n            val after = runSync { player.inventory.getItem(0) }\n            hasConsumableRemoval(after) shouldBe false\n        }\n\n        test(\"inventory click keeps slot and cursor food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val view =\n                runSync { player.openInventory(player.inventory) }\n                    ?: error(\"inventory view missing\")\n            try {\n                runSync {\n                    player.inventory.setItem(0, ItemStack(Material.BREAD))\n                    player.setItemOnCursor(ItemStack(Material.CARROT))\n                }\n\n                val slotItem = runSync { player.inventory.getItem(0) }\n                val cursorItem = runSync { player.itemOnCursor }\n                assertNoConsumableRemoval(slotItem, \"slot food (before)\")\n                assertNoConsumableRemoval(cursorItem, \"cursor food (before)\")\n\n                val event =\n                    runSync {\n                        val click =\n                            InventoryClickEvent(\n                                view,\n                                InventoryType.SlotType.CONTAINER,\n                                0,\n                                ClickType.LEFT,\n                                InventoryAction.PICKUP_ALL,\n                            )\n                        click.currentItem = slotItem\n                        click.cursor = cursorItem\n                        click\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n                delayTicks(1)\n\n                val afterSlot = runSync { player.inventory.getItem(0) }\n                val afterCursor = runSync { player.itemOnCursor }\n                hasConsumableRemoval(afterSlot) shouldBe false\n                hasConsumableRemoval(afterCursor) shouldBe false\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"inventory click does not alter swords when no consumable change is needed\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItem(1, ItemStack(Material.STONE))\n                player.inventory.heldItemSlot = 1\n                player.setItemOnCursor(ItemStack(Material.IRON_SWORD))\n            }\n\n            val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n            try {\n                val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }\n                val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }\n                applyConsumableComponent(slotItem)\n                applyConsumableComponent(cursorItem)\n                assertNoConsumableRemoval(slotItem, \"slot sword (before)\")\n                assertNoConsumableRemoval(cursorItem, \"cursor sword (before)\")\n\n                val event =\n                    runSync {\n                        val click =\n                            InventoryClickEvent(\n                                view,\n                                InventoryType.SlotType.CONTAINER,\n                                0,\n                                ClickType.LEFT,\n                                InventoryAction.PICKUP_ALL,\n                            )\n                        click.currentItem = slotItem\n                        click.cursor = cursorItem\n                        click\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n                delayTicks(1)\n\n                val afterSlot = runSync { player.inventory.getItem(0) }\n                val afterCursor = runSync { player.itemOnCursor }\n                hasConsumableRemoval(afterSlot) shouldBe false\n                hasConsumableRemoval(afterCursor) shouldBe false\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"stale deferred click reapply does not taint newly selected main-hand sword\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.getItem(0)) shouldBe false\n                hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n            }\n\n            val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n            try {\n                val click =\n                    runSync {\n                        InventoryClickEvent(\n                            view,\n                            InventoryType.SlotType.CONTAINER,\n                            0,\n                            ClickType.NUMBER_KEY,\n                            InventoryAction.HOTBAR_SWAP,\n                            0,\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(click) }\n\n                // Change selection directly before deferred next-tick reapply runs.\n                runSync {\n                    player.inventory.heldItemSlot = 1\n                    player.updateInventory()\n                }\n\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.heldItemSlot shouldBe 1\n                    player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD\n                    hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"inventory click does not strip consumable component when sword-blocking disabled for player modeset\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"new\")\n            }\n\n            runSync {\n                swordBlocking.isEnabled(player) shouldBe false\n            }\n\n            val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n            try {\n                val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }\n                val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }\n                applyConsumableComponent(slotItem)\n                applyConsumableComponent(cursorItem)\n                assertNoConsumableRemoval(slotItem, \"slot sword (before)\")\n                assertNoConsumableRemoval(cursorItem, \"cursor sword (before)\")\n\n                val event =\n                    runSync {\n                        val click =\n                            InventoryClickEvent(\n                                view,\n                                InventoryType.SlotType.CONTAINER,\n                                0,\n                                ClickType.LEFT,\n                                InventoryAction.PICKUP_ALL,\n                            )\n                        click.currentItem = slotItem\n                        click.cursor = cursorItem\n                        click\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n\n                // Assert the same objects we supplied to the event were not mutated.\n                hasConsumableComponent(slotItem) shouldBe true\n                hasConsumableComponent(cursorItem) shouldBe true\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"inventory click does not strip consumable component when sword-blocking disabled in world defaults\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            withWorldModesets(listOf(\"new\")) {\n                runSync {\n                    setModeset(player, null)\n                }\n\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe false\n                }\n\n                val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n                try {\n                    val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }\n                    val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }\n                    applyConsumableComponent(slotItem)\n                    applyConsumableComponent(cursorItem)\n                    assertNoConsumableRemoval(slotItem, \"slot sword (before)\")\n                    assertNoConsumableRemoval(cursorItem, \"cursor sword (before)\")\n\n                    val event =\n                        runSync {\n                            val click =\n                                InventoryClickEvent(\n                                    view,\n                                    InventoryType.SlotType.CONTAINER,\n                                    0,\n                                    ClickType.LEFT,\n                                    InventoryAction.PICKUP_ALL,\n                                )\n                            click.currentItem = slotItem\n                            click.cursor = cursorItem\n                            click\n                        }\n\n                    runSync { Bukkit.getPluginManager().callEvent(event) }\n\n                    // Assert the same objects we supplied to the event were not mutated.\n                    hasConsumableComponent(slotItem) shouldBe true\n                    hasConsumableComponent(cursorItem) shouldBe true\n                } finally {\n                    runSync { player.closeInventory() }\n                }\n            }\n        }\n\n        test(\"modeset change to disabled strips sword consumable component from hand\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))\n            }\n\n            // Seed the component on the actual hand item.\n            runSync {\n                val main = player.inventory.itemInMainHand\n                applyConsumableComponent(main)\n                player.inventory.setItemInMainHand(main)\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n\n            // Change modeset so sword-blocking is disabled for this player.\n            runSync {\n                setModeset(player, \"new\")\n                swordBlocking.isEnabled(player) shouldBe false\n                // Simulate the plugin's modeset-change hook.\n                ModuleLoader.getModules().forEach { it.onModesetChange(player) }\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n            }\n        }\n\n        test(\"disabled_modules clears sword consumable component after reload\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))\n            }\n\n            runSync {\n                val main = player.inventory.itemInMainHand\n                applyConsumableComponent(main)\n                player.inventory.setItemInMainHand(main)\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n\n            withSwordBlockingDisabled {\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe false\n                }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"disabled_modules prevents sword consumable component on right-click\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            }\n\n            withSwordBlockingDisabled {\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe false\n                }\n\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"reload toggle disables and re-enables sword consumable component\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            }\n\n            rightClickMainHand(player)\n            delayTicks(1)\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n\n            withSwordBlockingDisabled {\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n\n            delayTicks(1)\n            rightClickMainHand(player)\n            delayTicks(1)\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n        }\n\n        test(\"paper-animation config false clears stale sword consumable component after reload\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))\n            }\n\n            runSync {\n                val main = player.inventory.itemInMainHand\n                applyConsumableComponent(main)\n                player.inventory.setItemInMainHand(main)\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n\n            withSwordBlockingPaperAnimation(false) {\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe true\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"paper-animation config false prevents swap lifecycle from re-applying sword consumable component\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n            }\n\n            withSwordBlockingPaperAnimation(false) {\n                val event =\n                    runSync {\n                        PlayerSwapHandItemsEvent(\n                            player,\n                            player.inventory.itemInMainHand.clone(),\n                            player.inventory.itemInOffHand.clone(),\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n                delayTicks(1)\n\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe true\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"disabled_modules clears stored sword consumable components after reload\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.STONE))\n            }\n\n            runSync {\n                val stored = craftMirrorStack(Material.IRON_SWORD)\n                applyConsumableComponent(stored)\n                player.inventory.setItem(2, stored)\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.getItem(2)) shouldBe true\n            }\n\n            withSwordBlockingDisabled {\n                runSync {\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe false\n                }\n            }\n        }\n\n        test(\"disabled_modules keeps offhand unchanged on right-click\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n            }\n\n            val offhandBefore = runSync { player.inventory.itemInOffHand }\n            assertNoConsumableRemoval(offhandBefore, \"offhand item (before disabled right-click)\")\n\n            withSwordBlockingDisabled {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.APPLE\n                    hasConsumableRemoval(player.inventory.itemInOffHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"old client uses offhand shield instead of consumable animation\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"unknown client version uses offhand shield fallback instead of consumable animation\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val unknownVersion =\n                runCatching { unknownPacketEventsClientVersionName() }.getOrNull()\n                    ?: run {\n                        println(\"Skipping: PacketEvents unknown client version enum constant unavailable\")\n                        return@test\n                    }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            withPacketEventsClientVersion(player, unknownVersion) {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"missing PacketEvents client-version resolver fails safe to offhand shield fallback\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            val moduleClass = ModuleSwordBlocking::class.java\n            val packetEventsGetClientVersionField = moduleClass.getDeclaredField(\"packetEventsGetClientVersion\")\n            val minClientVersionField = moduleClass.getDeclaredField(\"minClientVersion\")\n            packetEventsGetClientVersionField.isAccessible = true\n            minClientVersionField.isAccessible = true\n\n            val originalGetClientVersion = runSync { packetEventsGetClientVersionField.get(swordBlocking) }\n            val originalMinClientVersion = runSync { minClientVersionField.get(swordBlocking) }\n\n            try {\n                runSync {\n                    packetEventsGetClientVersionField.set(swordBlocking, null)\n                    if (minClientVersionField.get(swordBlocking) == null) {\n                        minClientVersionField.set(swordBlocking, packetEventsClientVersion(\"V_1_20_5\"))\n                    }\n                }\n\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            } finally {\n                runSync {\n                    packetEventsGetClientVersionField.set(swordBlocking, originalGetClientVersion)\n                    minClientVersionField.set(swordBlocking, originalMinClientVersion)\n                }\n            }\n        }\n\n        test(\"middle-click in custom GUI does not mutate held sword components\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n            }\n\n            val gui = runSync { Bukkit.createInventory(null, 9, \"OCM Test GUI\") }\n            runSync {\n                gui.setItem(0, ItemStack(Material.STONE))\n            }\n\n            val view = runSync { player.openInventory(gui) } ?: error(\"inventory view missing\")\n            try {\n                val event =\n                    runSync {\n                        val click =\n                            InventoryClickEvent(\n                                view,\n                                InventoryType.SlotType.CONTAINER,\n                                0,\n                                ClickType.MIDDLE,\n                                InventoryAction.CLONE_STACK,\n                            )\n                        click.currentItem = gui.getItem(0)\n                        click\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n                delayTicks(1)\n\n                runSync {\n                    event.isCancelled shouldBe false\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"inventory drag in custom GUI does not rewrite top-inventory swords\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.STONE))\n            }\n\n            val gui = runSync { Bukkit.createInventory(null, 9, \"OCM Drag GUI\") }\n            val topSword = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }\n            applyConsumableComponent(topSword)\n            runSync {\n                hasConsumableComponent(topSword) shouldBe true\n                gui.setItem(0, topSword)\n            }\n\n            val view = runSync { player.openInventory(gui) } ?: error(\"inventory view missing\")\n            try {\n                val drag =\n                    runSync {\n                        InventoryDragEvent(\n                            view,\n                            ItemStack(Material.CARROT),\n                            ItemStack(Material.CARROT),\n                            false,\n                            mapOf(0 to ItemStack(Material.CARROT)),\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(drag) }\n                delayTicks(1)\n\n                val afterTop = runSync { gui.getItem(0) }\n                runSync {\n                    drag.isCancelled shouldBe false\n                    hasConsumableComponent(afterTop) shouldBe true\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"stale deferred drag reapply does not taint newly selected main-hand sword\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.getItem(0)) shouldBe false\n                hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n            }\n\n            val gui = runSync { Bukkit.createInventory(null, 9, \"OCM Drag Stale Slot\") }\n            val view = runSync { player.openInventory(gui) } ?: error(\"inventory view missing\")\n            try {\n                val drag =\n                    runSync {\n                        InventoryDragEvent(\n                            view,\n                            ItemStack(Material.CARROT),\n                            ItemStack(Material.CARROT),\n                            false,\n                            mapOf(9 to ItemStack(Material.CARROT)),\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(drag) }\n\n                // Move to a different held slot before deferred next-tick reapply runs.\n                runSync {\n                    player.inventory.heldItemSlot = 1\n                    player.updateInventory()\n                }\n\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.heldItemSlot shouldBe 1\n                    player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD\n                    hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"stale deferred drag context does not mutate dragged bottom slot\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            val gui = runSync { Bukkit.createInventory(null, 9, \"OCM Drag Cleanup Stale\") }\n            val view = runSync { player.openInventory(gui) } ?: error(\"inventory view missing\")\n            try {\n                val draggedSword = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }\n                applyConsumableComponent(draggedSword)\n                runSync {\n                    hasConsumableComponent(draggedSword) shouldBe true\n                    view.setItem(9, draggedSword)\n                }\n\n                val drag =\n                    runSync {\n                        InventoryDragEvent(\n                            view,\n                            ItemStack(Material.CARROT),\n                            ItemStack(Material.CARROT),\n                            false,\n                            mapOf(9 to ItemStack(Material.CARROT)),\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(drag) }\n\n                // Move to a different held slot before deferred next-tick reapply runs.\n                runSync {\n                    player.inventory.heldItemSlot = 1\n                    player.updateInventory()\n                }\n\n                delayTicks(1)\n\n                val slotAfter = runSync { view.getItem(9) }\n                runSync {\n                    player.inventory.heldItemSlot shouldBe 1\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                    hasConsumableComponent(slotAfter) shouldBe true\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"stale deferred drag reapply no-ops after inventory view change\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n            }\n\n            val firstGui = runSync { Bukkit.createInventory(null, 9, \"OCM Drag First View\") }\n            val firstView = runSync { player.openInventory(firstGui) } ?: error(\"first inventory view missing\")\n\n            try {\n                val drag =\n                    runSync {\n                        InventoryDragEvent(\n                            firstView,\n                            ItemStack(Material.CARROT),\n                            ItemStack(Material.CARROT),\n                            false,\n                            mapOf(9 to ItemStack(Material.CARROT)),\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(drag) }\n\n                val secondGui = runSync { Bukkit.createInventory(null, 9, \"OCM Drag Second View\") }\n                runSync {\n                    player.openInventory(secondGui)\n                }\n\n                delayTicks(1)\n\n                runSync {\n                    player.openInventory.topInventory shouldBe secondGui\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"old client shield fallback restores offhand item on hotbar change\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItem(1, ItemStack(Material.STONE))\n                player.inventory.heldItemSlot = 0\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                }\n\n                runSync {\n                    player.inventory.heldItemSlot = 1\n                    Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1))\n                }\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.APPLE\n                }\n            }\n        }\n\n        test(\"legacy fallback does not cancel custom GUI shield-icon clicks\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                }\n\n                val gui = runSync { Bukkit.createInventory(null, 9, \"Shield GUI\") }\n                runSync {\n                    gui.setItem(0, ItemStack(Material.SHIELD))\n                }\n\n                val view = runSync { player.openInventory(gui) } ?: error(\"inventory view missing\")\n                try {\n                    val click =\n                        runSync {\n                            val event =\n                                InventoryClickEvent(\n                                    view,\n                                    InventoryType.SlotType.CONTAINER,\n                                    0,\n                                    ClickType.LEFT,\n                                    InventoryAction.PICKUP_ALL,\n                                )\n                            event.currentItem = gui.getItem(0)\n                            event\n                        }\n\n                    runSync { Bukkit.getPluginManager().callEvent(click) }\n                    delayTicks(1)\n\n                    runSync {\n                        click.isCancelled shouldBe false\n                    }\n                } finally {\n                    runSync { player.closeInventory() }\n                }\n            }\n        }\n\n        test(\"legacy fallback does not cancel dropping unrelated shield items\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                }\n\n                val dropped =\n                    runSync {\n                        player.world.dropItem(\n                            player.location,\n                            ItemStack(Material.SHIELD).apply {\n                                itemMeta = itemMeta?.apply { setDisplayName(\"Unrelated Shield\") }\n                            },\n                        )\n                    }\n\n                try {\n                    val dropEvent = runSync { PlayerDropItemEvent(player, dropped) }\n                    runSync { Bukkit.getPluginManager().callEvent(dropEvent) }\n\n                    runSync {\n                        dropEvent.isCancelled shouldBe false\n                    }\n                } finally {\n                    runSync { dropped.remove() }\n                }\n            }\n        }\n\n        test(\"legacy fallback still blocks swapping temporary offhand shield\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                }\n\n                val swapEvent =\n                    runSync {\n                        PlayerSwapHandItemsEvent(\n                            player,\n                            player.inventory.itemInMainHand,\n                            player.inventory.itemInOffHand,\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(swapEvent) }\n\n                runSync {\n                    swapEvent.isCancelled shouldBe true\n                }\n            }\n        }\n\n        test(\"legacy death replaces only temporary shield drop and clears legacy state once\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n                player.updateInventory()\n            }\n\n            val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField(\"storedItems\")\n            val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField(\"legacyStates\")\n            storedItemsField.isAccessible = true\n            legacyStatesField.isAccessible = true\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap<UUID, ItemStack> }\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    storedItems.containsKey(player.uniqueId) shouldBe true\n                    val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>\n                    legacyStates.containsKey(player.uniqueId) shouldBe true\n                }\n\n                val drops =\n                    mutableListOf(\n                        ItemStack(Material.SHIELD),\n                        ItemStack(Material.SHIELD),\n                    )\n                val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }\n\n                runSync { Bukkit.getPluginManager().callEvent(deathEvent) }\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val dropsAfter = runSync { (deathEvent.drops as MutableList<ItemStack?>).toList() }\n\n                runSync {\n                    dropsAfter.any { it == null } shouldBe false\n                    dropsAfter.count { it?.type == Material.APPLE } shouldBe 1\n                    dropsAfter.count { it?.type == Material.SHIELD } shouldBe 1\n                    storedItems.containsKey(player.uniqueId) shouldBe false\n                    val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>\n                    legacyStates.containsKey(player.uniqueId) shouldBe false\n                }\n            }\n        }\n\n        test(\"legacy death with keepInventory does not rewrite drops and still clears state\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n                player.updateInventory()\n            }\n\n            val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField(\"storedItems\")\n            val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField(\"legacyStates\")\n            storedItemsField.isAccessible = true\n            legacyStatesField.isAccessible = true\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap<UUID, ItemStack> }\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    storedItems.containsKey(player.uniqueId) shouldBe true\n                }\n\n                val drops = mutableListOf(ItemStack(Material.SHIELD), ItemStack(Material.STONE))\n                val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }\n                runSync {\n                    deathEvent.keepInventory = true\n                }\n\n                runSync { Bukkit.getPluginManager().callEvent(deathEvent) }\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val dropsAfter = runSync { (deathEvent.drops as MutableList<ItemStack?>).toList() }\n\n                runSync {\n                    dropsAfter.map { it?.type } shouldBe listOf(Material.SHIELD, Material.STONE)\n                    player.inventory.itemInOffHand.type shouldBe Material.APPLE\n                    storedItems.containsKey(player.uniqueId) shouldBe false\n                    val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>\n                    legacyStates.containsKey(player.uniqueId) shouldBe false\n                }\n            }\n        }\n\n        test(\"paper-animation swap restores stale stored offhand item before clearing legacy state\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.SHIELD))\n                player.updateInventory()\n            }\n\n            val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField(\"storedItems\")\n            val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField(\"legacyStates\")\n            storedItemsField.isAccessible = true\n            legacyStatesField.isAccessible = true\n\n            @Suppress(\"UNCHECKED_CAST\")\n            val storedItems = storedItemsField.get(swordBlocking) as MutableMap<UUID, ItemStack>\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                runSync {\n                    storedItems[player.uniqueId] = ItemStack(Material.APPLE)\n                }\n\n                val swapEvent =\n                    runSync {\n                        PlayerSwapHandItemsEvent(\n                            player,\n                            player.inventory.itemInMainHand,\n                            player.inventory.itemInOffHand,\n                        )\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(swapEvent) }\n\n                runSync {\n                    swapEvent.isCancelled shouldBe false\n                    player.inventory.itemInOffHand.type shouldBe Material.APPLE\n                    storedItems.containsKey(player.uniqueId) shouldBe false\n                    val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>\n                    legacyStates.containsKey(player.uniqueId) shouldBe false\n                }\n            }\n        }\n\n        test(\"modeset change after disabled reload does not reapply sword consumable component\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))\n            }\n\n            runSync {\n                val main = player.inventory.itemInMainHand\n                applyConsumableComponent(main)\n                player.inventory.setItemInMainHand(main)\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n            }\n\n            withSwordBlockingDisabled {\n                runSync {\n                    setModeset(player, \"new\")\n                    ModuleLoader.getModules().forEach { it.onModesetChange(player) }\n                }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n\n                runSync {\n                    setModeset(player, \"old\")\n                    ModuleLoader.getModules().forEach { it.onModesetChange(player) }\n                }\n\n                runSync {\n                    swordBlocking.isEnabled(player) shouldBe false\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                }\n            }\n        }\n\n        test(\"number-key hotbar swap keeps food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val view =\n                runSync { player.openInventory(player.inventory) }\n                    ?: error(\"inventory view missing\")\n            try {\n                runSync {\n                    player.inventory.setItem(0, ItemStack(Material.STONE))\n                    player.inventory.setItem(2, ItemStack(Material.BREAD))\n                }\n\n                val hotbarItem = runSync { player.inventory.getItem(2) }\n                assertNoConsumableRemoval(hotbarItem, \"hotbar button food (before)\")\n\n                val event =\n                    runSync {\n                        val click =\n                            InventoryClickEvent(\n                                view,\n                                InventoryType.SlotType.CONTAINER,\n                                0,\n                                ClickType.NUMBER_KEY,\n                                InventoryAction.HOTBAR_SWAP,\n                                2,\n                            )\n                        click.currentItem = player.inventory.getItem(0)\n                        click\n                    }\n\n                runSync { Bukkit.getPluginManager().callEvent(event) }\n                delayTicks(1)\n\n                val after = runSync { player.inventory.getItem(2) }\n                hasConsumableRemoval(after) shouldBe false\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"stale deferred swap reapply does not taint newly selected main-hand sword\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                val slot0Sword = craftMirrorStack(Material.DIAMOND_SWORD)\n                applyConsumableComponent(slot0Sword)\n                hasConsumableComponent(slot0Sword) shouldBe true\n                player.inventory.setItem(0, slot0Sword)\n                player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.STICK))\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            runSync {\n                hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n            }\n\n            val event =\n                runSync {\n                    PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)\n                }\n            runSync { Bukkit.getPluginManager().callEvent(event) }\n            runSync {\n                val main = player.inventory.itemInMainHand\n                val off = player.inventory.itemInOffHand\n                player.inventory.setItemInMainHand(off)\n                player.inventory.setItemInOffHand(main)\n            }\n\n            // Change held slot directly before deferred next-tick swap handling runs.\n            runSync {\n                player.inventory.heldItemSlot = 1\n                player.updateInventory()\n            }\n\n            delayTicks(1)\n\n            runSync {\n                player.inventory.heldItemSlot shouldBe 1\n                player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD\n                hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n                player.inventory.itemInOffHand.type shouldBe Material.DIAMOND_SWORD\n                hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false\n            }\n        }\n\n        test(\"stale deferred swap reapply no-ops when inventory view changes, while offhand cleanup still runs\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n\n                val mainSword = craftMirrorStack(Material.DIAMOND_SWORD)\n                applyConsumableComponent(mainSword)\n                hasConsumableComponent(mainSword) shouldBe true\n                player.inventory.setItem(0, mainSword)\n\n                val offhandSword = craftMirrorStack(Material.IRON_SWORD)\n                applyConsumableComponent(offhandSword)\n                hasConsumableComponent(offhandSword) shouldBe true\n                player.inventory.setItemInOffHand(offhandSword)\n\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            val firstGui = runSync { Bukkit.createInventory(null, 9, \"OCM Swap First View\") }\n            runSync {\n                player.openInventory(firstGui)\n            }\n\n            val swapEvent =\n                runSync {\n                    PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)\n                }\n            runSync { Bukkit.getPluginManager().callEvent(swapEvent) }\n            runSync {\n                val main = player.inventory.itemInMainHand\n                val off = player.inventory.itemInOffHand\n                player.inventory.setItemInMainHand(off)\n                player.inventory.setItemInOffHand(main)\n            }\n\n            // Keep the same held slot but change the open view before deferred task runs.\n            val secondGui = runSync { Bukkit.createInventory(null, 9, \"OCM Swap Second View\") }\n            runSync {\n                player.inventory.setItem(0, ItemStack(Material.GOLDEN_SWORD))\n                player.openInventory(secondGui)\n                player.updateInventory()\n            }\n\n            delayTicks(1)\n\n            runSync {\n                player.openInventory.topInventory shouldBe secondGui\n                player.inventory.heldItemSlot shouldBe 0\n                player.inventory.itemInMainHand.type shouldBe Material.GOLDEN_SWORD\n                hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                player.inventory.itemInOffHand.type shouldBe Material.DIAMOND_SWORD\n                hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false\n            }\n\n            runSync { player.closeInventory() }\n        }\n\n        test(\"swap hand keeps food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.STONE))\n                player.inventory.setItemInOffHand(ItemStack(Material.BREAD))\n            }\n\n            val offhandBefore = runSync { player.inventory.itemInOffHand }\n            assertNoConsumableRemoval(offhandBefore, \"offhand food (before)\")\n\n            val event =\n                runSync {\n                    PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)\n                }\n            runSync { Bukkit.getPluginManager().callEvent(event) }\n            runSync {\n                val main = player.inventory.itemInMainHand\n                val off = player.inventory.itemInOffHand\n                player.inventory.setItemInMainHand(off)\n                player.inventory.setItemInOffHand(main)\n            }\n            delayTicks(1)\n\n            val mainAfter = runSync { player.inventory.itemInMainHand }\n            val offAfter = runSync { player.inventory.itemInOffHand }\n            val foodAfter = if (mainAfter.type == Material.BREAD) mainAfter else offAfter\n            hasConsumableRemoval(foodAfter) shouldBe false\n        }\n\n        test(\"dropping food keeps consumable component\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val drop =\n                runSync {\n                    player.world.dropItem(player.location, ItemStack(Material.BREAD))\n                }\n            try {\n                val before = runSync { drop.itemStack }\n                assertNoConsumableRemoval(before, \"dropped food (before)\")\n\n                runSync { Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop)) }\n\n                val after = runSync { drop.itemStack }\n                hasConsumableRemoval(after) shouldBe false\n            } finally {\n                runSync { drop.remove() }\n            }\n        }\n\n        test(\"world change keeps hand food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.BREAD))\n                player.inventory.setItemInOffHand(ItemStack(Material.CARROT))\n            }\n\n            val beforeMain = runSync { player.inventory.itemInMainHand }\n            val beforeOff = runSync { player.inventory.itemInOffHand }\n            assertNoConsumableRemoval(beforeMain, \"main hand food (before world change)\")\n            assertNoConsumableRemoval(beforeOff, \"offhand food (before world change)\")\n\n            runSync { Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(player, player.world)) }\n\n            val afterMain = runSync { player.inventory.itemInMainHand }\n            val afterOff = runSync { player.inventory.itemInOffHand }\n            hasConsumableRemoval(afterMain) shouldBe false\n            hasConsumableRemoval(afterOff) shouldBe false\n        }\n\n        test(\"quit event keeps hand food consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.BREAD))\n                player.inventory.setItemInOffHand(ItemStack(Material.CARROT))\n            }\n\n            val beforeMain = runSync { player.inventory.itemInMainHand }\n            val beforeOff = runSync { player.inventory.itemInOffHand }\n            assertNoConsumableRemoval(beforeMain, \"main hand food (before quit)\")\n            assertNoConsumableRemoval(beforeOff, \"offhand food (before quit)\")\n\n            runSync { Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, \"test\")) }\n\n            val afterMain = runSync { player.inventory.itemInMainHand }\n            val afterOff = runSync { player.inventory.itemInOffHand }\n            hasConsumableRemoval(afterMain) shouldBe false\n            hasConsumableRemoval(afterOff) shouldBe false\n        }\n\n        test(\"world change strips consumable component from hand and stored swords\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                val main = craftMirrorStack(Material.DIAMOND_SWORD)\n                val off = craftMirrorStack(Material.IRON_SWORD)\n                val stored = craftMirrorStack(Material.GOLDEN_SWORD)\n                applyConsumableComponent(main)\n                applyConsumableComponent(off)\n                applyConsumableComponent(stored)\n                player.inventory.setItemInMainHand(main)\n                player.inventory.setItemInOffHand(off)\n                player.inventory.setItem(2, stored)\n                player.updateInventory()\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe true\n                }\n\n                runSync { Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(player, player.world)) }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe false\n                }\n            }\n        }\n\n        test(\"dropping sword strips consumable component from dropped stack\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val drop =\n                runSync {\n                    val droppedSword = craftMirrorStack(Material.DIAMOND_SWORD)\n                    applyConsumableComponent(droppedSword)\n                    player.world.dropItem(player.location, droppedSword)\n                }\n            try {\n                withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                    runSync {\n                        hasConsumableComponent(drop.itemStack) shouldBe true\n                    }\n\n                    runSync { Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop)) }\n\n                    runSync {\n                        hasConsumableComponent(drop.itemStack) shouldBe false\n                    }\n                }\n            } finally {\n                runSync { drop.remove() }\n            }\n        }\n\n        test(\"death event strips consumable component from sword drops\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            val drops = mutableListOf(runSync { craftMirrorStack(Material.DIAMOND_SWORD) })\n            runSync {\n                applyConsumableComponent(drops[0])\n                hasConsumableComponent(drops[0]) shouldBe true\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }\n                runSync { Bukkit.getPluginManager().callEvent(deathEvent) }\n\n                runSync {\n                    hasConsumableComponent(drops[0]) shouldBe false\n                }\n            }\n        }\n\n        test(\"held-slot transition strips previous sword and applies new sword consumable\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                val previous = craftMirrorStack(Material.DIAMOND_SWORD)\n                val next = craftMirrorStack(Material.IRON_SWORD)\n                applyConsumableComponent(previous)\n                player.inventory.setItem(0, previous)\n                player.inventory.setItem(1, next)\n                player.inventory.heldItemSlot = 0\n                player.updateInventory()\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                runSync {\n                    hasConsumableComponent(player.inventory.getItem(0)) shouldBe true\n                    hasConsumableComponent(player.inventory.getItem(1)) shouldBe false\n                }\n\n                runSync { Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1)) }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.getItem(0)) shouldBe false\n                    hasConsumableComponent(player.inventory.getItem(1)) shouldBe true\n                }\n            }\n        }\n\n        test(\"quit event strips consumable component from hand and stored swords\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                val main = craftMirrorStack(Material.DIAMOND_SWORD)\n                val off = craftMirrorStack(Material.IRON_SWORD)\n                val stored = craftMirrorStack(Material.GOLDEN_SWORD)\n                applyConsumableComponent(main)\n                applyConsumableComponent(off)\n                applyConsumableComponent(stored)\n                player.inventory.setItemInMainHand(main)\n                player.inventory.setItemInOffHand(off)\n                player.inventory.setItem(2, stored)\n                player.updateInventory()\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe true\n                }\n\n                runSync { Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, \"test\")) }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe false\n                }\n            }\n        }\n\n        test(\"join event strips consumable component from hand and stored swords\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                val main = craftMirrorStack(Material.DIAMOND_SWORD)\n                val off = craftMirrorStack(Material.IRON_SWORD)\n                val stored = craftMirrorStack(Material.GOLDEN_SWORD)\n                applyConsumableComponent(main)\n                applyConsumableComponent(off)\n                applyConsumableComponent(stored)\n                player.inventory.setItemInMainHand(main)\n                player.inventory.setItemInOffHand(off)\n                player.inventory.setItem(2, stored)\n                player.updateInventory()\n            }\n\n            withPacketEventsClientVersion(player, \"V_1_21_11\") {\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe true\n                }\n\n                runSync { Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, \"test\")) }\n\n                runSync {\n                    hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false\n                    hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false\n                    hasConsumableComponent(player.inventory.getItem(2)) shouldBe false\n                }\n            }\n        }\n\n        test(\"modeset disable clears stale legacy fallback shield state\") {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n                return@test\n            }\n\n            runSync {\n                setModeset(player, \"old\")\n                player.gameMode = GameMode.SURVIVAL\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.APPLE))\n                player.updateInventory()\n            }\n\n            val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField(\"storedItems\")\n            val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField(\"legacyStates\")\n            storedItemsField.isAccessible = true\n            legacyStatesField.isAccessible = true\n\n            withPacketEventsClientVersion(player, \"V_1_20_3\") {\n                rightClickMainHand(player)\n                delayTicks(1)\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap<UUID, ItemStack> }\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    storedItems.containsKey(player.uniqueId) shouldBe true\n                }\n\n                runSync {\n                    setModeset(player, \"new\")\n                    swordBlocking.isEnabled(player) shouldBe false\n                    ModuleLoader.getModules().forEach { it.onModesetChange(player) }\n                }\n\n                runSync {\n                    player.inventory.itemInOffHand.type shouldBe Material.APPLE\n                    storedItems.containsKey(player.uniqueId) shouldBe false\n                    val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>\n                    legacyStates.containsKey(player.uniqueId) shouldBe false\n                }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/CopperToolsIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XMaterial\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.StringSpec\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages\nimport org.bukkit.Material\nimport org.bukkit.plugin.java.JavaPlugin\n\n@OptIn(ExperimentalKotest::class)\nclass CopperToolsIntegrationTest : StringSpec({\n    val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    extension(MainThreadDispatcherExtension(plugin))\n    val copperMaterials = listOf(\n        XMaterial.COPPER_SWORD,\n        XMaterial.COPPER_AXE,\n        XMaterial.COPPER_PICKAXE,\n        XMaterial.COPPER_SHOVEL,\n        XMaterial.COPPER_HOE\n    ).mapNotNull { xmat ->\n        val material = runCatching { Material.valueOf(xmat.name) }.getOrNull()\n        material?.let { xmat to it }\n    }\n\n    if (copperMaterials.isEmpty()) {\n        \"copper tools not present on this version\" {\n            plugin.logger.info(\"Copper tools not present on this version; skipping copper damage checks.\")\n        }\n    } else {\n        copperMaterials.forEach { (xmat, material) ->\n            \"copper tool damage is configurable for ${xmat.name}\" {\n                val damage = WeaponDamages.getDamage(material)\n                if (damage < 0.0) {\n                    throw AssertionError(\"Expected ${xmat.name} to be present in old-tool-damage.damages (config.yml)\")\n                }\n            }\n        }\n    }\n\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/CustomWeaponDamageIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.doubles.shouldBeExactly\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.entity.Trident\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\nimport java.util.concurrent.atomic.AtomicReference\n\n@OptIn(ExperimentalKotest::class)\nclass CustomWeaponDamageIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val toolDamageModule = ModuleLoader.getModules()\n        .filterIsInstance<ModuleOldToolDamage>()\n        .firstOrNull() ?: error(\"ModuleOldToolDamage not registered\")\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n            action()\n            null\n        }).get()\n    }\n\n    suspend fun TestScope.withWeaponConfig(\n        tridentMelee: Double?,\n        tridentThrown: Double?,\n        mace: Double?,\n        block: suspend TestScope.() -> Unit\n    ) {\n        val snapshot = ocm.config.getConfigurationSection(\"old-tool-damage.damages\")?.getValues(false) ?: emptyMap<String, Any?>()\n\n        fun set(path: String, value: Double?) {\n            if (value == null) return\n            ocm.config.set(\"old-tool-damage.damages.$path\", value)\n        }\n\n        try {\n            set(\"TRIDENT\", tridentMelee)\n            set(\"TRIDENT_THROWN\", tridentThrown)\n            set(\"MACE\", mace)\n            toolDamageModule.reload()\n            WeaponDamages.initialise(ocm)\n            ModuleLoader.toggleModules()\n            block()\n        } finally {\n            // restore\n            snapshot.forEach { (k, v) -> ocm.config.set(\"old-tool-damage.damages.$k\", v) }\n            toolDamageModule.reload()\n            WeaponDamages.initialise(ocm)\n            ModuleLoader.toggleModules()\n        }\n    }\n\n    data class SpawnedPlayer(val fake: FakePlayer, val player: Player)\n\n    fun spawnFake(location: Location): SpawnedPlayer {\n        lateinit var fake: FakePlayer\n        lateinit var player: Player\n        runSync {\n            fake = FakePlayer(testPlugin)\n            fake.spawn(location)\n            player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n            player.gameMode = GameMode.SURVIVAL\n            player.isInvulnerable = false\n            player.inventory.clear()\n            player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n            val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n            data.setModesetForWorld(player.world.uid, \"old\")\n            kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)\n        }\n        return SpawnedPlayer(fake, player)\n    }\n\n    fun cleanup(vararg players: SpawnedPlayer) {\n        runSync {\n            players.forEach { p ->\n                p.fake.removePlayer()\n            }\n        }\n    }\n\n    test(\"trident melee uses configured base damage\") {\n        val tridentMat = Material.matchMaterial(\"TRIDENT\") ?: return@test\n        withWeaponConfig(tridentMelee = 12.0, tridentThrown = null, mace = null) {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))\n            val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))\n            val damageCapture = AtomicReference<Double?>()\n            val listener = object : Listener {\n                @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                fun onHit(event: EntityDamageByEntityEvent) {\n                    if (event.damager == attacker.player && event.entity == victim.player) {\n                        damageCapture.set(event.damage)\n                    }\n                }\n            }\n\n            runSync {\n                Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                attacker.player.inventory.setItemInMainHand(ItemStack(tridentMat))\n                Bukkit.getPluginManager().callEvent(\n                    EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 8.0)\n                )\n                HandlerList.unregisterAll(listener)\n            }\n\n            val dealt = damageCapture.get() ?: error(\"No damage recorded\")\n            dealt shouldBe (12.0 plusOrMinus 0.05)\n            cleanup(attacker, victim)\n        }\n    }\n\n    test(\"thrown trident uses configured damage\") {\n        val tridentMat = Material.matchMaterial(\"TRIDENT\") ?: return@test\n        if (!Reflector.versionIsNewerOrEqualTo(1, 13, 0)) return@test\n        withWeaponConfig(tridentMelee = null, tridentThrown = 15.0, mace = null) {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            val victim = spawnFake(Location(world, 0.0, 100.0, 0.0))\n            val tridentRef = AtomicReference<Trident>()\n            runSync {\n                tridentRef.set(\n                    world.spawn(world.spawnLocation, Trident::class.java).apply {\n                        this.item = ItemStack(tridentMat)\n                    }\n                )\n            }\n            val damageCapture = AtomicReference<Double?>()\n            val listener = object : Listener {\n                @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                fun onHit(event: EntityDamageByEntityEvent) {\n                    if (event.damager == tridentRef.get() && event.entity == victim.player) {\n                        damageCapture.set(event.damage)\n                    }\n                }\n            }\n            runSync {\n                Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                Bukkit.getPluginManager().callEvent(\n                    EntityDamageByEntityEvent(tridentRef.get(), victim.player, EntityDamageEvent.DamageCause.PROJECTILE, 8.0)\n                )\n                HandlerList.unregisterAll(listener)\n            }\n\n            val dealt = damageCapture.get() ?: error(\"No damage recorded\")\n            dealt shouldBeExactly 15.0\n            cleanup(victim)\n            runSync { tridentRef.get()?.remove() }\n        }\n    }\n\n    test(\"mace melee uses configured base damage\") {\n        val maceMat = Material.matchMaterial(\"MACE\") ?: return@test\n        withWeaponConfig(tridentMelee = null, tridentThrown = null, mace = 10.0) {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))\n            val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))\n            val damageCapture = AtomicReference<Double?>()\n            val listener = object : Listener {\n                @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                fun onHit(event: EntityDamageByEntityEvent) {\n                    if (event.damager == attacker.player && event.entity == victim.player) {\n                        damageCapture.set(event.damage)\n                    }\n                }\n            }\n\n            runSync {\n                Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                attacker.player.inventory.setItemInMainHand(ItemStack(maceMat))\n                Bukkit.getPluginManager().callEvent(\n                    EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 6.0)\n                )\n                HandlerList.unregisterAll(listener)\n            }\n\n            val dealt = damageCapture.get() ?: error(\"No damage recorded\")\n            dealt shouldBe (10.0 plusOrMinus 0.05)\n            cleanup(attacker, victim)\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/DisableOffhandIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass DisableOffhandIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleDisableOffHand>()\n        .firstOrNull() ?: error(\"ModuleDisableOffHand not registered\")\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        setPlayerData(player.uniqueId, playerData)\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakePlayer.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            player.inventory.clear()\n            player.inventory.setItemInOffHand(ItemStack(Material.SHIELD))\n            setModeset(player, \"new\")\n        }\n    }\n\n    test(\"modeset-change handler ignores players without disable-offhand enabled\") {\n        runSync {\n            module.isEnabled(player) shouldBe false\n            val offhand = player.inventory.itemInOffHand.clone()\n\n            module.onModesetChange(player)\n\n            player.inventory.itemInOffHand.type shouldBe offhand.type\n            player.inventory.itemInOffHand.amount shouldBe offhand.amount\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/DisableOffhandReflectionIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.entity.Player\nimport org.bukkit.event.inventory.ClickType\nimport org.bukkit.event.inventory.InventoryAction\nimport org.bukkit.event.inventory.InventoryClickEvent\nimport org.bukkit.event.inventory.InventoryType\nimport org.bukkit.inventory.Inventory\nimport org.bukkit.inventory.InventoryView\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass DisableOffhandReflectionIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun <T> runSync(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()\n            }\n\n        lateinit var fake: FakePlayer\n        lateinit var player: Player\n\n        beforeSpec {\n            runSync {\n                val world = Bukkit.getWorld(\"world\") ?: error(\"world missing\")\n                fake = FakePlayer(testPlugin)\n                fake.spawn(Location(world, 0.0, 100.0, 0.0))\n                player = Bukkit.getPlayer(fake.uuid) ?: error(\"player missing\")\n                player.gameMode = GameMode.SURVIVAL\n                player.isInvulnerable = false\n            }\n        }\n\n        afterSpec {\n            runSync {\n                fake.removePlayer()\n            }\n        }\n\n        beforeTest {\n            runSync {\n                player.closeInventory()\n            }\n        }\n\n        test(\"reflective InventoryClickEvent getView works\") {\n            val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n            try {\n                val event =\n                    runSync {\n                        InventoryClickEvent(\n                            view,\n                            InventoryType.SlotType.CONTAINER,\n                            0,\n                            ClickType.LEFT,\n                            InventoryAction.PICKUP_ALL,\n                        )\n                    }\n                val method = Reflector.getMethod(event.javaClass, \"getView\") ?: error(\"getView missing\")\n                val reflectedView = Reflector.invokeMethod<InventoryView>(method, event)\n                reflectedView shouldBe view\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n\n        test(\"reflective InventoryView access returns top and bottom inventories\") {\n            val view = runSync { player.openInventory(player.inventory) } ?: error(\"inventory view missing\")\n            try {\n                val bottomMethod =\n                    Reflector.getMethod(view.javaClass, \"getBottomInventory\")\n                        ?: error(\"getBottomInventory missing\")\n                val topMethod =\n                    Reflector.getMethod(view.javaClass, \"getTopInventory\")\n                        ?: error(\"getTopInventory missing\")\n\n                val bottom =\n                    Reflector.invokeMethod<Inventory>(bottomMethod, view)\n                        ?: error(\"bottom inventory missing\")\n                val top =\n                    Reflector.invokeMethod<Inventory>(topMethod, view)\n                        ?: error(\"top inventory missing\")\n\n                bottom.type shouldBe InventoryType.PLAYER\n            } finally {\n                runSync { player.closeInventory() }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/EnderpearlCooldownIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.longs.shouldBeGreaterThan\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleDisableEnderpearlCooldown\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.EnderPearl\nimport org.bukkit.entity.Player\nimport org.bukkit.event.entity.ProjectileLaunchEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass EnderpearlCooldownIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules().filterIsInstance<ModuleDisableEnderpearlCooldown>().firstOrNull()\n        ?: error(\"ModuleDisableEnderpearlCooldown not registered\")\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n            action()\n        }).get()\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n        ModuleLoader.toggleModules()\n    }\n\n    suspend fun withConfig(cooldownSeconds: Int, showMessage: Boolean, block: suspend () -> Unit) {\n        val oldCooldown = ocm.config.getInt(\"disable-enderpearl-cooldown.cooldown\")\n        val oldShow = ocm.config.getBoolean(\"disable-enderpearl-cooldown.showMessage\")\n        try {\n            runSync {\n                ocm.config.set(\"disable-enderpearl-cooldown.cooldown\", cooldownSeconds)\n                ocm.config.set(\"disable-enderpearl-cooldown.showMessage\", showMessage)\n                module.reload()\n                ModuleLoader.toggleModules()\n            }\n            block()\n        } finally {\n            runSync {\n                ocm.config.set(\"disable-enderpearl-cooldown.cooldown\", oldCooldown)\n                ocm.config.set(\"disable-enderpearl-cooldown.showMessage\", oldShow)\n                module.reload()\n                ModuleLoader.toggleModules()\n            }\n        }\n    }\n\n    fun firePearlLaunchEvent(player: Player): ProjectileLaunchEvent {\n        // Spawn a pearl entity directly; the module cancels this and launches a replacement.\n        val pearl = runSync {\n            val world = player.world\n            val entity = world.spawn(player.eyeLocation, EnderPearl::class.java)\n            entity.shooter = player\n            entity\n        }\n        val event = ProjectileLaunchEvent(pearl)\n        runSync { Bukkit.getPluginManager().callEvent(event) }\n        return event\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getWorld(\"world\") ?: error(\"world not loaded\")\n            val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n            player = Bukkit.getPlayer(fakePlayer.uuid) ?: error(\"Player not found\")\n            player.isOp = true\n            setModeset(player, \"old\")\n        }\n    }\n\n    afterSpec {\n        runSync { fakePlayer.removePlayer() }\n    }\n\n    beforeTest {\n        runSync {\n            setModeset(player, \"old\")\n            player.gameMode = GameMode.SURVIVAL\n            player.inventory.clear()\n            player.inventory.setItemInMainHand(ItemStack(Material.ENDER_PEARL, 16))\n        }\n    }\n\n    test(\"cooldown 0 allows repeated throws and consumes an enderpearl in survival\") {\n        withConfig(cooldownSeconds = 0, showMessage = false) {\n            val before = runSync { player.inventory.itemInMainHand.amount }\n            val e1 = firePearlLaunchEvent(player)\n            e1.isCancelled shouldBe true\n            val after1 = runSync { player.inventory.itemInMainHand.amount }\n            (before - after1) shouldBe 1\n\n            val e2 = firePearlLaunchEvent(player)\n            e2.isCancelled shouldBe true\n            val after2 = runSync { player.inventory.itemInMainHand.amount }\n            (after1 - after2) shouldBe 1\n        }\n    }\n\n    test(\"cooldown blocks a second throw within the window and exposes remaining cooldown\") {\n        withConfig(cooldownSeconds = 5, showMessage = false) {\n            val e1 = firePearlLaunchEvent(player)\n            e1.isCancelled shouldBe true\n\n            val remainingAfterFirst = runSync { module.getEnderpearlCooldown(player.uniqueId) }\n            remainingAfterFirst shouldBeGreaterThan 0\n\n            val before2 = runSync { player.inventory.itemInMainHand.amount }\n            val e2 = firePearlLaunchEvent(player)\n            e2.isCancelled shouldBe true\n\n            // Second throw attempt should not consume another pearl.\n            val after2 = runSync { player.inventory.itemInMainHand.amount }\n            before2 shouldBe after2\n        }\n    }\n\n    test(\"creative mode does not consume enderpearls\") {\n        withConfig(cooldownSeconds = 0, showMessage = false) {\n            runSync { player.gameMode = GameMode.CREATIVE }\n            val before = runSync { player.inventory.itemInMainHand.amount }\n            val e1 = firePearlLaunchEvent(player)\n            e1.isCancelled shouldBe true\n            val after = runSync { player.inventory.itemInMainHand.amount }\n            before shouldBe after\n        }\n    }\n\n    test(\"no enderpearl item in either hand does not throw or consume\") {\n        withConfig(cooldownSeconds = 0, showMessage = false) {\n            runSync { player.inventory.clear() }\n            val e1 = firePearlLaunchEvent(player)\n            e1.isCancelled shouldBe true\n            runSync { player.inventory.itemInMainHand.type shouldBe Material.AIR }\n        }\n    }\n\n    test(\"cooldown expires after real time and throw becomes allowed again\") {\n        withConfig(cooldownSeconds = 1, showMessage = false) {\n            val e1 = firePearlLaunchEvent(player)\n            e1.isCancelled shouldBe true\n\n            // Wait slightly over a second to ensure wall-clock cooldown is over.\n            delay(1200)\n\n            val before2 = runSync { player.inventory.itemInMainHand.amount }\n            val e2 = firePearlLaunchEvent(player)\n            e2.isCancelled shouldBe true\n            val after2 = runSync { player.inventory.itemInMainHand.amount }\n            (before2 - after2) shouldBe 1\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.mojang.authlib.GameProfile\nimport io.netty.channel.ChannelInboundHandlerAdapter\nimport io.netty.channel.ChannelOutboundHandlerAdapter\nimport io.netty.channel.embedded.EmbeddedChannel\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.player.AsyncPlayerPreLoginEvent\nimport org.bukkit.event.player.PlayerJoinEvent\nimport org.bukkit.event.player.PlayerPreLoginEvent\nimport org.bukkit.event.player.PlayerQuitEvent\nimport org.bukkit.plugin.java.JavaPlugin\nimport xyz.jpenilla.reflectionremapper.ReflectionRemapper\nimport java.lang.reflect.Method\nimport java.net.InetAddress\nimport java.net.InetSocketAddress\nimport java.net.SocketAddress\nimport java.net.UnknownHostException\nimport java.util.*\n\nclass FakePlayer(private val plugin: JavaPlugin) {\n    val uuid: UUID = UUID.randomUUID()\n    private val name: String = uuid.toString().substring(0, 16)\n    private lateinit var serverPlayer: Any // NMS ServerPlayer instance\n    private var bukkitPlayer: Player? = null\n    private var networkConnection: Any? = null\n    private var usedPlaceNewPlayer: Boolean = false\n    private var tickTaskId: Int? = null\n    private val isLegacy9 = !Reflector.versionIsNewerOrEqualTo(1, 10, 0) // 1.9.x and below\n    private val isLegacy12 = !Reflector.versionIsNewerOrEqualTo(1, 13, 0) && Reflector.versionIsNewerOrEqualTo(1, 10, 0)\n    private val legacyImpl9: LegacyFakePlayer9? = if (isLegacy9) LegacyFakePlayer9(plugin, uuid, name) else null\n    private val legacyImpl12: LegacyFakePlayer12? = if (isLegacy12) LegacyFakePlayer12(plugin, uuid, name) else null\n    private val reflectionRemapper: ReflectionRemapper = try {\n        ReflectionRemapper.forReobfMappingsInPaperJar()\n    } catch (e: Throwable) {\n        plugin.logger.warning(\"Reflection mappings not found; using no-op remapper for legacy server.\")\n        ReflectionRemapper.noop()\n    }\n\n    // Helper function to load NMS classes using the appropriate class loader and remap names\n    fun getNMSClass(name: String): Class<*> {\n        // Remap the class name\n        val remappedName = reflectionRemapper.remapClassName(name)\n        // Get the NMS MinecraftServer from the Bukkit server\n        val server = Bukkit.getServer()\n        val craftServerClass = server.javaClass\n        val getServerMethod = Reflector.getMethod(craftServerClass, \"getServer\")\n            ?: throw NoSuchMethodException(\"Cannot find getServer method in ${craftServerClass.name}\")\n        val minecraftServer = Reflector.invokeMethod<Any>(getServerMethod, server)\n\n        return Class.forName(remappedName, true, minecraftServer.javaClass.classLoader)\n    }\n\n    fun spawn(location: Location) {\n        if (isLegacy9) {\n            legacyImpl9!!.spawn(location)\n            serverPlayer = legacyImpl9.entityPlayer ?: throw IllegalStateException(\"Legacy9 entity player not created.\")\n            bukkitPlayer = legacyImpl9.bukkitPlayer\n            return\n        }\n        if (isLegacy12) {\n            legacyImpl12!!.spawn(location)\n            serverPlayer = legacyImpl12.entityPlayer ?: throw IllegalStateException(\"Legacy12 entity player not created.\")\n            bukkitPlayer = legacyImpl12.bukkitPlayer\n            return\n        }\n        plugin.logger.info(\"Spawn: Starting\")\n\n        // Get the NMS WorldServer (ServerLevel) from the Bukkit world\n        val world = location.world ?: throw IllegalArgumentException(\"Location has no world!\")\n        val craftWorldClass = world.javaClass\n        val getHandleMethod = Reflector.getMethod(craftWorldClass, \"getHandle\")\n            ?: throw NoSuchMethodException(\"Cannot find getHandle method in ${craftWorldClass.name}\")\n        val worldServer = Reflector.invokeMethod<Any>(getHandleMethod, world)\n\n        val minecraftServer = getMinecraftServer()\n\n        // Create a GameProfile for the fake player\n        val gameProfile = GameProfile(uuid, name)\n\n        // Get the ServerPlayer class and its constructor\n        val minecraftServerClass = getNMSClass(\"net.minecraft.server.MinecraftServer\")\n        val serverPlayerClass = getNMSClass(\"net.minecraft.server.level.ServerPlayer\")\n\n        // Create a new instance of ServerPlayer (constructor signature varies by version)\n        this.serverPlayer = createServerPlayer(\n            serverPlayerClass,\n            minecraftServerClass,\n            minecraftServer,\n            worldServer,\n            gameProfile\n        )\n        plugin.logger.info(\"Spawn: created serverPlayer\")\n\n        // Set up the connection for the ServerPlayer\n        setupPlayerConnection(minecraftServer, worldServer)\n\n        // Set the GameMode to SURVIVAL\n        setPlayerGameMode(\"SURVIVAL\", minecraftServer)\n\n        setPlayerPosition(location)\n        setPlayerRotation(0f, 0f)\n\n        // Fire AsyncPlayerPreLoginEvent\n        fireAsyncPlayerPreLoginEvent()\n\n        // Add the player to the server's player list\n        usedPlaceNewPlayer = addToPlayerList(minecraftServer)\n\n        // Retrieve the Bukkit Player instance\n        bukkitPlayer = Bukkit.getPlayer(uuid)\n            ?: throw RuntimeException(\"Bukkit player with UUID $uuid not found!\")\n\n        // Fire PlayerJoinEvent\n        firePlayerJoinEvent()\n\n        // Notify other players and spawn the fake player\n        if (!usedPlaceNewPlayer) {\n            notifyPlayersOfJoin()\n            spawnPlayerInWorld(worldServer, minecraftServer)\n        } else if (bukkitPlayer?.world?.players?.contains(bukkitPlayer) != true) {\n            spawnPlayerInWorld(worldServer, minecraftServer)\n        }\n\n        scheduleServerPlayerTick()\n\n        plugin.logger.info(\"Spawn: completed successfully\")\n    }\n\n    private fun setupPlayerConnection(minecraftServer: Any, worldServer: Any) {\n        // Access ServerGamePacketListenerImpl class\n        val serverGamePacketListenerImplClass = getNMSClass(\"net.minecraft.server.network.ServerGamePacketListenerImpl\")\n\n        // Create a new Connection object\n        val connectionClass = getNMSClass(\"net.minecraft.network.Connection\")\n        val packetFlowClass = getNMSClass(\"net.minecraft.network.protocol.PacketFlow\")\n        val serverboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, \"SERVERBOUND\")\n        val packetFlow = runCatching {\n            Reflector.getEnumConstant(packetFlowClass, serverboundFieldName, \"SERVERBOUND\")\n        }.getOrElse {\n            val clientboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, \"CLIENTBOUND\")\n            Reflector.getEnumConstant(packetFlowClass, clientboundFieldName, \"CLIENTBOUND\")\n        }\n        val connectionConstructor = connectionClass.getConstructor(packetFlowClass)\n        val connection = connectionConstructor.newInstance(packetFlow)\n        networkConnection = connection\n\n        // Create a custom EmbeddedChannel with an overridden remoteAddress()\n        val remoteAddress = InetSocketAddress(\"127.0.0.1\", 9999)\n        val embeddedChannel = EmbeddedChannel(ChannelInboundHandlerAdapter())\n        val pipeline = embeddedChannel.pipeline()\n        if (pipeline.get(\"decoder\") == null) {\n            pipeline.addLast(\"decoder\", ChannelInboundHandlerAdapter())\n        }\n        if (pipeline.get(\"encoder\") == null) {\n            pipeline.addLast(\"encoder\", ChannelOutboundHandlerAdapter())\n        }\n\n        // Set the 'channel' field of 'connection' to the custom EmbeddedChannel\n        val channelFieldName = reflectionRemapper.remapFieldName(connectionClass, \"channel\")\n        val channelField = Reflector.getField(connectionClass, channelFieldName)\n        channelField.isAccessible = true\n        channelField.set(connection, embeddedChannel)\n\n        // Set address field of connection\n        val addressFieldName = reflectionRemapper.remapFieldName(connectionClass, \"address\")\n        val addressField = Reflector.getField(connectionClass, addressFieldName)\n        addressField.set(connection, remoteAddress)\n\n        // Create a new ServerGamePacketListenerImpl instance (constructor signature varies by version)\n        val serverPlayerClass = serverPlayer.javaClass\n        val minecraftServerClass = getNMSClass(\"net.minecraft.server.MinecraftServer\")\n        val listenerInstance = createServerGamePacketListener(\n            serverGamePacketListenerImplClass,\n            minecraftServerClass,\n            connectionClass,\n            serverPlayerClass,\n            minecraftServer,\n            connection,\n            serverPlayer\n        )\n\n        // Set the listenerInstance to the player's 'connection' field\n        val connectionFieldName = reflectionRemapper.remapFieldName(serverPlayerClass, \"connection\")\n        val connectionField = Reflector.getField(serverPlayerClass, connectionFieldName)\n        Reflector.setFieldValue(connectionField, serverPlayer, listenerInstance)\n\n        val setListenerName = reflectionRemapper.remapMethodName(\n            connectionClass,\n            \"setListener\",\n            listenerInstance.javaClass\n        )\n        val setListenerMethod = Reflector.getMethodAssignable(\n            connectionClass,\n            setListenerName,\n            listenerInstance.javaClass\n        ) ?: Reflector.getMethodAssignable(connectionClass, \"setListener\", listenerInstance.javaClass)\n        if (setListenerMethod != null) {\n            Reflector.invokeMethod<Any>(setListenerMethod, connection, listenerInstance)\n        }\n    }\n\n    private fun createServerGamePacketListener(\n        listenerClass: Class<*>,\n        minecraftServerClass: Class<*>,\n        connectionClass: Class<*>,\n        serverPlayerClass: Class<*>,\n        minecraftServer: Any,\n        connection: Any,\n        serverPlayer: Any\n    ): Any {\n        val constructors = listenerClass.constructors.sortedBy { it.parameterCount }\n        for (ctor in constructors) {\n            val params = ctor.parameterTypes\n            if (params.size < 3) continue\n            if (!params[0].isAssignableFrom(minecraftServerClass)) continue\n            if (!params[1].isAssignableFrom(connectionClass)) continue\n            if (!params[2].isAssignableFrom(serverPlayerClass)) continue\n\n            val args = ArrayList<Any?>()\n            args.add(minecraftServer)\n            args.add(connection)\n            args.add(serverPlayer)\n\n            var supported = true\n            for (i in 3 until params.size) {\n                val param = params[i]\n                when (param.simpleName) {\n                    \"CommonListenerCookie\" -> args.add(createCommonListenerCookie(param, serverPlayer))\n                    else -> {\n                        supported = false\n                        break\n                    }\n                }\n            }\n\n            if (!supported) continue\n            return ctor.newInstance(*args.toTypedArray())\n        }\n\n        throw NoSuchMethodException(\"No compatible ServerGamePacketListenerImpl constructor found for ${listenerClass.name}\")\n    }\n\n    private fun createCommonListenerCookie(cookieClass: Class<*>, serverPlayer: Any): Any {\n        val getProfileName = reflectionRemapper.remapMethodName(serverPlayer.javaClass, \"getGameProfile\")\n        val getProfileMethod = Reflector.getMethod(serverPlayer.javaClass, getProfileName)\n            ?: Reflector.getMethod(serverPlayer.javaClass, \"getGameProfile\")\n            ?: throw NoSuchMethodException(\"getGameProfile not found in ${serverPlayer.javaClass.name}\")\n        val gameProfile = Reflector.invokeMethod<GameProfile>(getProfileMethod, serverPlayer)\n\n        val remappedName = reflectionRemapper.remapMethodName(\n            cookieClass,\n            \"createInitial\",\n            GameProfile::class.java,\n            Boolean::class.javaPrimitiveType\n        )\n        val method = Reflector.getMethod(cookieClass, remappedName, \"GameProfile\", \"boolean\")\n            ?: Reflector.getMethod(cookieClass, \"createInitial\", \"GameProfile\", \"boolean\")\n            ?: throw NoSuchMethodException(\"createInitial not found in ${cookieClass.name}\")\n        return Reflector.invokeMethod(method, null, gameProfile, false)\n    }\n\n    private fun createServerPlayer(\n        serverPlayerClass: Class<*>,\n        minecraftServerClass: Class<*>,\n        minecraftServer: Any,\n        worldServer: Any,\n        gameProfile: GameProfile\n    ): Any {\n        val constructors = serverPlayerClass.constructors.sortedBy { it.parameterCount }\n        for (ctor in constructors) {\n            val params = ctor.parameterTypes\n            if (params.size < 3) continue\n            if (!params[0].isAssignableFrom(minecraftServerClass)) continue\n            if (!params[1].isAssignableFrom(worldServer.javaClass)) continue\n            if (params[2] != GameProfile::class.java) continue\n\n            val args = ArrayList<Any?>()\n            args.add(minecraftServer)\n            args.add(worldServer)\n            args.add(gameProfile)\n\n            var supported = true\n            for (i in 3 until params.size) {\n                val param = params[i]\n                when (param.simpleName) {\n                    \"ProfilePublicKey\" -> args.add(null)\n                    \"ClientInformation\" -> args.add(createDefaultClientInformation(param))\n                    else -> {\n                        supported = false\n                        break\n                    }\n                }\n            }\n\n            if (!supported) continue\n            return ctor.newInstance(*args.toTypedArray())\n        }\n\n        throw NoSuchMethodException(\"No compatible ServerPlayer constructor found for ${serverPlayerClass.name}\")\n    }\n\n    private fun createDefaultClientInformation(clientInfoClass: Class<*>): Any {\n        val remappedName = reflectionRemapper.remapMethodName(clientInfoClass, \"createDefault\")\n        val method = Reflector.getMethod(clientInfoClass, remappedName)\n            ?: Reflector.getMethod(clientInfoClass, \"createDefault\")\n            ?: throw NoSuchMethodException(\"createDefault not found in ${clientInfoClass.name}\")\n        return Reflector.invokeMethod(method, null)\n    }\n\n    private fun setPlayerGameMode(gameModeName: String, minecraftServer: Any) {\n        val gameModeClass = getNMSClass(\"net.minecraft.world.level.GameType\")\n        val gameModeFieldName = reflectionRemapper.remapFieldName(gameModeClass, gameModeName)\n        val gameModeField = Reflector.getField(gameModeClass, gameModeFieldName)\n        val gameMode = gameModeField.get(null)\n        val setGameModeMethodName = reflectionRemapper.remapMethodName(\n            serverPlayer.javaClass,\n            \"setGameMode\",\n            gameModeClass\n        )\n        val setGameModeMethod = serverPlayer.javaClass.getMethod(setGameModeMethodName, gameModeClass)\n        setGameModeMethod.invoke(serverPlayer, gameMode)\n    }\n\n    private fun setPlayerPosition(location: Location) {\n        val entityClass = getNMSClass(\"net.minecraft.world.entity.Entity\")\n        val setPosMethodName = reflectionRemapper.remapMethodName(\n            entityClass,\n            \"setPos\",\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType\n        )\n        val setPosMethod = checkNotNull(\n            Reflector.getMethod(\n                entityClass,\n                setPosMethodName,\n                \"double\",\n                \"double\",\n                \"double\"\n            )\n        )\n        setPosMethod.invoke(serverPlayer, location.x, location.y, location.z)\n    }\n\n    private fun setPlayerRotation(xRot: Float, yRot: Float) {\n        val entityClass = getNMSClass(\"net.minecraft.world.entity.Entity\")\n        val xRotFieldName = reflectionRemapper.remapFieldName(entityClass, \"xRot\")\n        val xRotField = Reflector.getField(entityClass, xRotFieldName)\n        xRotField.setFloat(serverPlayer, xRot)\n\n        val yRotFieldName = reflectionRemapper.remapFieldName(entityClass, \"yRot\")\n        val yRotField = Reflector.getField(entityClass, yRotFieldName)\n        yRotField.setFloat(serverPlayer, yRot)\n    }\n\n    private fun fireAsyncPlayerPreLoginEvent() {\n        try {\n            val ipAddress = InetAddress.getByName(\"127.0.0.1\")\n            @Suppress(\"DEPRECATION\") // Legacy constructor kept for older server compatibility in tests.\n            val asyncPreLoginEvent = AsyncPlayerPreLoginEvent(name, ipAddress, uuid)\n            Thread { Bukkit.getPluginManager().callEvent(asyncPreLoginEvent) }.start()\n        } catch (e: UnknownHostException) {\n            plugin.logger.severe(\"Spawn: UnknownHostException - ${e.message}\")\n            e.printStackTrace()\n        }\n    }\n\n    private fun addToPlayerList(minecraftServer: Any): Boolean {\n        val playerList = getPlayerList(minecraftServer)\n\n        // Add the player to the PlayerList\n        val playerListClass = getNMSClass(\"net.minecraft.server.players.PlayerList\")\n        val placeMethodName = reflectionRemapper.remapMethodName(playerListClass, \"placeNewPlayer\")\n        val connection = checkNotNull(networkConnection) { \"Connection not initialised\" }\n        val placeMethodWithCookie = Reflector.getMethodAssignable(\n            playerListClass,\n            placeMethodName,\n            connection.javaClass,\n            serverPlayer.javaClass,\n            null\n        ) ?: Reflector.getMethodAssignable(\n            playerListClass,\n            \"placeNewPlayer\",\n            connection.javaClass,\n            serverPlayer.javaClass,\n            null\n        )\n\n        if (placeMethodWithCookie != null && placeMethodWithCookie.parameterTypes.size == 3) {\n            val cookieClass = placeMethodWithCookie.parameterTypes[2]\n            val cookie = createCommonListenerCookie(cookieClass, serverPlayer)\n            placeMethodWithCookie.invoke(playerList, connection, serverPlayer, cookie)\n            return true\n        }\n\n        val placeMethod = Reflector.getMethodAssignable(\n            playerListClass,\n            placeMethodName,\n            connection.javaClass,\n            serverPlayer.javaClass\n        ) ?: Reflector.getMethodAssignable(\n            playerListClass,\n            \"placeNewPlayer\",\n            connection.javaClass,\n            serverPlayer.javaClass\n        )\n\n        if (placeMethod != null) {\n            placeMethod.invoke(playerList, connection, serverPlayer)\n            return true\n        }\n\n        val loadMethodName = reflectionRemapper.remapMethodName(playerListClass, \"load\", serverPlayer.javaClass)\n        val loadMethod = playerListClass.methods.firstOrNull { method ->\n            method.name == loadMethodName &&\n                method.parameterCount == 1 &&\n                method.parameterTypes[0].isAssignableFrom(serverPlayer.javaClass)\n        }\n\n        if (loadMethod != null) {\n            Reflector.invokeMethod<Any>(loadMethod, playerList, serverPlayer)\n\n            val playersFieldName = reflectionRemapper.remapFieldName(playerListClass, \"players\")\n            val playersField = playerListClass.getDeclaredField(playersFieldName)\n            playersField.isAccessible = true\n            @Suppress(\"UNCHECKED_CAST\") // Reflection into NMS collection; types vary by version.\n            val players = playersField.get(playerList) as MutableList<Any>\n            players.add(serverPlayer)\n\n            // Add player to the UUID map\n            val playersByUUIDField = Reflector.getMapFieldWithTypes(\n                playerListClass, UUID::class.java, serverPlayer.javaClass\n            )\n            @Suppress(\"UNCHECKED_CAST\") // Reflection into NMS map; types vary by version.\n            val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as MutableMap<UUID, Any>\n            playerByUUID[uuid] = serverPlayer\n            return false\n        }\n\n        throw NoSuchMethodException(\"No compatible PlayerList add method found for ${playerListClass.name}\")\n    }\n\n    private fun firePlayerJoinEvent() {\n        val joinMessage = \"$name joined the game\"\n        val playerJoinEvent = PlayerJoinEvent(bukkitPlayer!!, joinMessage)\n        Bukkit.getPluginManager().callEvent(playerJoinEvent)\n    }\n\n    private fun notifyPlayersOfJoin() {\n        if (Bukkit.getOnlinePlayers().isEmpty()) return\n\n        val packet = runCatching { createLegacyPlayerInfoPacket() }.getOrNull()\n            ?: runCatching { createPlayerInfoUpdatePacket() }.getOrNull()\n            ?: return\n        sendPacket(packet)\n    }\n\n    private fun spawnPlayerInWorld(worldServer: Any, minecraftServer: Any) {\n        // Add the player to the world\n        val worldServerClass = worldServer.javaClass\n        val addNewPlayerMethodName = reflectionRemapper.remapMethodName(\n            worldServerClass,\n            \"addNewPlayer\",\n            serverPlayer.javaClass\n        )\n        val addNewPlayerMethod = Reflector.getMethodAssignable(\n            worldServerClass,\n            addNewPlayerMethodName,\n            serverPlayer.javaClass\n        )\n            ?: Reflector.getMethodAssignable(worldServerClass, \"addNewPlayer\", serverPlayer.javaClass)\n            ?: Reflector.getMethodAssignable(worldServerClass, \"addPlayer\", serverPlayer.javaClass)\n            ?: Reflector.getMethodAssignable(worldServerClass, \"addFreshEntity\", serverPlayer.javaClass)\n            ?: Reflector.getMethodAssignable(worldServerClass, \"addEntity\", serverPlayer.javaClass)\n        if (addNewPlayerMethod != null) {\n            addNewPlayerMethod.invoke(worldServer, serverPlayer)\n        } else {\n            plugin.logger.warning(\"Spawn: Could not find a world add method for ${worldServerClass.name}\")\n        }\n\n        // Send world info to the player\n        val minecraftServerClass = getNMSClass(\"net.minecraft.server.MinecraftServer\")\n        runCatching {\n            val getStatusMethodName = reflectionRemapper.remapMethodName(minecraftServerClass, \"getStatus\")\n            val getStatusMethod = Reflector.getMethod(minecraftServerClass, getStatusMethodName)\n            val status = Reflector.invokeMethod<Any>(getStatusMethod!!, minecraftServer)\n\n            val sendServerStatusMethodName = reflectionRemapper.remapMethodName(\n                serverPlayer.javaClass,\n                \"sendServerStatus\",\n                status.javaClass\n            )\n            val sendServerStatusMethod = Reflector.getMethod(\n                serverPlayer.javaClass,\n                sendServerStatusMethodName,\n                status.javaClass.simpleName\n            ) ?: Reflector.getMethod(serverPlayer.javaClass, \"sendServerStatus\", status.javaClass.simpleName)\n            if (sendServerStatusMethod != null) {\n                sendServerStatusMethod.invoke(serverPlayer, status)\n            }\n        }\n\n        // Send ClientboundAddPlayerPacket to all players\n        runCatching {\n            val clientboundAddPlayerPacketClass =\n                getNMSClass(\"net.minecraft.network.protocol.game.ClientboundAddPlayerPacket\")\n            val playerClassName = getNMSClass(\"net.minecraft.world.entity.player.Player\")\n            // ServerPlayer is subclass of Player\n            val clientboundAddPlayerPacketConstructor = clientboundAddPlayerPacketClass.getConstructor(playerClassName)\n\n            val packet = clientboundAddPlayerPacketConstructor.newInstance(serverPlayer)\n            sendPacket(packet)\n        }\n    }\n\n    private fun createLegacyPlayerInfoPacket(): Any {\n        val clientboundPlayerInfoPacketClass =\n            getNMSClass(\"net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket\")\n        val actionClass = getNMSClass(\"net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket\\$Action\")\n        val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, \"ADD_PLAYER\")\n        val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)\n\n        val clientboundPlayerInfoPacketConstructor = clientboundPlayerInfoPacketClass.getConstructor(\n            actionClass,\n            Collection::class.java\n        )\n\n        return clientboundPlayerInfoPacketConstructor.newInstance(\n            addPlayerAction,\n            listOf(serverPlayer)\n        )\n    }\n\n    private fun createPlayerInfoUpdatePacket(): Any {\n        val packetClass = getNMSClass(\"net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket\")\n        val actionClass = getNMSClass(\"net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket\\$Action\")\n        val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, \"ADD_PLAYER\")\n        val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)\n\n        val createInitMethodName = reflectionRemapper.remapMethodName(\n            packetClass,\n            \"createPlayerInitializing\",\n            serverPlayer.javaClass\n        )\n        val createInitMethod = Reflector.getMethodAssignable(\n            packetClass,\n            createInitMethodName,\n            serverPlayer.javaClass\n        ) ?: Reflector.getMethodAssignable(packetClass, \"createPlayerInitializing\", serverPlayer.javaClass)\n        if (createInitMethod != null) {\n            return Reflector.invokeMethod(createInitMethod, null, serverPlayer)\n        }\n\n        val constructor = packetClass.getConstructor(EnumSet::class.java, Collection::class.java)\n        val enumSetNoneOf = EnumSet::class.java.getMethod(\"noneOf\", Class::class.java)\n        @Suppress(\"UNCHECKED_CAST\")\n        val actions = enumSetNoneOf.invoke(null, actionClass) as MutableSet<Any>\n        actions.add(addPlayerAction)\n        return constructor.newInstance(actions, listOf(serverPlayer))\n    }\n\n    private fun sendPacket(packet: Any) {\n        // Get the Packet class\n        val packetClass = getNMSClass(\"net.minecraft.network.protocol.Packet\")\n\n        // Send packet to all online players\n        Bukkit.getOnlinePlayers().forEach { player ->\n            val craftPlayerClass = player.javaClass\n            val getHandleMethodName = reflectionRemapper.remapMethodName(craftPlayerClass, \"getHandle\")\n            val getHandleMethod = craftPlayerClass.getMethod(getHandleMethodName)\n            val entityPlayer = getHandleMethod.invoke(player)\n\n            val connection = getConnection(entityPlayer)\n\n            val sendMethodName = reflectionRemapper.remapMethodName(connection.javaClass, \"send\", packetClass)\n            val sendMethod = connection.javaClass.getMethod(sendMethodName, packetClass)\n            sendMethod.invoke(connection, packet)\n        }\n    }\n\n    fun getConnection(serverPlayer: Any): Any {\n        if (isLegacy9) return legacyImpl9!!.getConnection(serverPlayer)\n        if (isLegacy12) return legacyImpl12!!.getConnection(serverPlayer)\n        val entityPlayerClass = serverPlayer.javaClass\n        val connectionFieldName = reflectionRemapper.remapFieldName(entityPlayerClass, \"connection\")\n        val connectionField = Reflector.getField(entityPlayerClass, connectionFieldName)\n        if (connectionField != null) return connectionField.get(serverPlayer)\n        val legacyField = Reflector.getField(entityPlayerClass, \"playerConnection\")\n        return legacyField?.get(serverPlayer) ?: error(\"No connection field on ${entityPlayerClass.name}\")\n    }\n\n    fun removePlayer() {\n        tickTaskId?.let { Bukkit.getScheduler().cancelTask(it) }\n        if (isLegacy9) {\n            legacyImpl9!!.removePlayer()\n            return\n        }\n        if (isLegacy12) {\n            legacyImpl12!!.removePlayer()\n            return\n        }\n        // Fire PlayerQuitEvent\n        val quitMessage = \"§e$name left the game\"\n        val playerQuitEvent = PlayerQuitEvent(bukkitPlayer!!, quitMessage)\n        Bukkit.getPluginManager().callEvent(playerQuitEvent)\n\n        // Disconnect the player\n        bukkitPlayer!!.kickPlayer(quitMessage)\n\n        // Remove the player from the world\n        /*\n    val removeMethodName = reflectionRemapper.remapMethodName(\n        serverPlayer.javaClass,\n        \"remove\"\n    )\n    val removeMethod = serverPlayer.javaClass.getMethod(removeMethodName)\n    removeMethod.invoke(serverPlayer)\n         */\n\n        // Remove from playerList if still present and not already removed\n        runCatching {\n            val playerList = getPlayerList(getMinecraftServer())\n            if (!isEntityRemoved(serverPlayer) && isPlayerListed(playerList)) {\n                val removePlayerMethodName = reflectionRemapper.remapMethodName(\n                    playerList.javaClass,\n                    \"remove\",\n                    serverPlayer.javaClass\n                )\n                val removePlayerMethod = playerList.javaClass.getMethod(removePlayerMethodName, serverPlayer.javaClass)\n                removePlayerMethod.invoke(playerList, serverPlayer)\n            }\n        }\n\n        // Close the connection properly\n        val connection = getConnection(serverPlayer)\n        val disconnectMethodName = reflectionRemapper.remapMethodName(\n            connection.javaClass,\n            \"disconnect\",\n            getNMSClass(\"net.minecraft.network.chat.Component\")\n        )\n        val disconnectMethod =\n            connection.javaClass.getMethod(disconnectMethodName, getNMSClass(\"net.minecraft.network.chat.Component\"))\n        val quitMessageNoColour = \"$name left the game\"\n        val disconnectMessage = getNMSComponent(quitMessageNoColour)\n        disconnectMethod.invoke(connection, disconnectMessage)\n    }\n\n    private fun scheduleServerPlayerTick() {\n        if (isLegacy9 || isLegacy12) return\n        val serverPlayerClass = serverPlayer.javaClass\n        val tickMethod = resolveServerPlayerTickMethod(serverPlayerClass)\n        val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)\n        val getRemainingFireTicksMethod = runCatching {\n            val entityClass = getNMSClass(\"net.minecraft.world.entity.Entity\")\n            val remapped = reflectionRemapper.remapMethodName(entityClass, \"getRemainingFireTicks\")\n            Reflector.getMethod(entityClass, remapped) ?: Reflector.getMethod(entityClass, \"getRemainingFireTicks\")\n        }.getOrNull()\n        tickTaskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {\n            val remainingFireTicks = if (getRemainingFireTicksMethod != null) {\n                runCatching { getRemainingFireTicksMethod.invoke(serverPlayer) as Int }.getOrNull()\n            } else {\n                null\n            }\n            if (remainingFireTicks != null && remainingFireTicks > 0) {\n                val player = bukkitPlayer\n                val inWater = player != null && (\n                    player.location.block.type == Material.WATER ||\n                        player.eyeLocation.block.type == Material.WATER\n                    )\n                if (inWater && tickMethod != null) {\n                    runCatching { tickMethod.invoke(serverPlayer) }\n                    return@Runnable\n                }\n                if (baseTickMethod != null) {\n                    runCatching { baseTickMethod.invoke(serverPlayer) }\n                    return@Runnable\n                }\n            }\n\n            if (tickMethod != null) {\n                runCatching { tickMethod.invoke(serverPlayer) }\n            } else if (baseTickMethod != null) {\n                runCatching { baseTickMethod.invoke(serverPlayer) }\n            }\n        }, 1L, 1L)\n    }\n\n    private fun resolveServerPlayerTickMethod(serverPlayerClass: Class<*>): Method? {\n        val candidateNames = listOf(\"doTick\", \"tick\")\n        for (name in candidateNames) {\n            val remapped = reflectionRemapper.remapMethodName(serverPlayerClass, name)\n            val method = Reflector.getMethod(serverPlayerClass, remapped) ?: Reflector.getMethod(serverPlayerClass, name)\n            if (method != null && method.parameterCount == 0) {\n                method.isAccessible = true\n                return method\n            }\n        }\n\n        val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)\n        if (baseTickMethod != null) {\n            return baseTickMethod\n        }\n\n        val fallback = serverPlayerClass.methods.firstOrNull { method ->\n            method.parameterCount == 0 &&\n                method.returnType == Void.TYPE &&\n                method.name.lowercase().contains(\"tick\")\n        } ?: serverPlayerClass.methods.firstOrNull { method ->\n            method.parameterCount == 0 && method.returnType == Void.TYPE\n        }\n\n        fallback?.isAccessible = true\n        return fallback\n    }\n\n    private fun resolveBaseTickMethod(serverPlayerClass: Class<*>): Method? {\n        val entityClass = runCatching { getNMSClass(\"net.minecraft.world.entity.Entity\") }.getOrNull()\n        val candidateNames = buildList {\n            entityClass?.let { add(reflectionRemapper.remapMethodName(it, \"baseTick\")) }\n            add(\"baseTick\")\n        }.distinct()\n        for (name in candidateNames) {\n            val method = (serverPlayerClass.declaredMethods + serverPlayerClass.methods).firstOrNull { candidate ->\n                candidate.name == name && candidate.parameterCount == 0\n            } ?: entityClass?.let { clazz ->\n                (clazz.declaredMethods + clazz.methods).firstOrNull { candidate ->\n                    candidate.name == name && candidate.parameterCount == 0\n                }\n            }\n            if (method != null) {\n                method.isAccessible = true\n                return method\n            }\n        }\n        return null\n    }\n\n    private fun getNMSComponent(message: String): Any {\n        // Convert a String to an NMS Component\n        val componentClass = getNMSClass(\"net.minecraft.network.chat.Component\")\n        val literalName = reflectionRemapper.remapMethodName(componentClass, \"literal\", String::class.java)\n        val componentMethod = checkNotNull(Reflector.getMethod(componentClass, literalName, \"String\"))\n        return componentMethod.invoke(null, message)\n    }\n\n    private fun getMinecraftServer(): Any {\n        val server = Bukkit.getServer()\n        val craftServerClass = server.javaClass\n        val getServerMethod = Reflector.getMethod(craftServerClass, \"getServer\")\n            ?: throw NoSuchMethodException(\"Cannot find getServer method in ${craftServerClass.name}\")\n        return Reflector.invokeMethod(getServerMethod, server)\n    }\n\n    private fun getPlayerList(minecraftServer: Any): Any {\n        val playerListFieldName = reflectionRemapper.remapMethodName(minecraftServer.javaClass, \"getPlayerList\")\n        val playerListMethod = checkNotNull(Reflector.getMethod(minecraftServer.javaClass, playerListFieldName))\n        return Reflector.invokeMethod(playerListMethod, minecraftServer)\n    }\n\n    private fun isPlayerListed(playerList: Any): Boolean {\n        val playerListClass = getNMSClass(\"net.minecraft.server.players.PlayerList\")\n        return runCatching {\n            val playersByUUIDField = Reflector.getMapFieldWithTypes(\n                playerListClass,\n                UUID::class.java,\n                serverPlayer.javaClass\n            )\n            @Suppress(\"UNCHECKED_CAST\") // Reflection into NMS map; types vary by version.\n            val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as Map<UUID, Any>\n            playerByUUID.containsKey(uuid)\n        }.getOrElse {\n            val getPlayerMethodName = reflectionRemapper.remapMethodName(playerListClass, \"getPlayer\", UUID::class.java)\n            val getPlayerMethod = Reflector.getMethodAssignable(\n                playerListClass,\n                getPlayerMethodName,\n                UUID::class.java\n            ) ?: Reflector.getMethodAssignable(playerListClass, \"getPlayer\", UUID::class.java)\n            if (getPlayerMethod != null) {\n                Reflector.invokeMethod<Any?>(getPlayerMethod, playerList, uuid) != null\n            } else {\n                true\n            }\n        }\n    }\n\n    private fun isEntityRemoved(entity: Any): Boolean {\n        val entityClass = getNMSClass(\"net.minecraft.world.entity.Entity\")\n        val isRemovedMethodName = reflectionRemapper.remapMethodName(entityClass, \"isRemoved\")\n        val isRemovedMethod = Reflector.getMethod(entityClass, isRemovedMethodName)\n            ?: Reflector.getMethod(entityClass, \"isRemoved\")\n        if (isRemovedMethod != null) {\n            return Reflector.invokeMethod(isRemovedMethod, entity)\n        }\n        return runCatching {\n            val removedFieldName = reflectionRemapper.remapFieldName(entityClass, \"removed\")\n            val removedField = Reflector.getField(entityClass, removedFieldName)\n            removedField.getBoolean(entity)\n        }.getOrDefault(false)\n    }\n\n    fun attack(bukkitEntity: Entity) {\n        attackCompat(checkNotNull(bukkitPlayer), bukkitEntity)\n    }\n\n    fun doBlocking() {\n        if (isLegacy9 || isLegacy12) {\n            // Legacy blocking: ensure sword in hand + shield in offhand, then raise offhand.\n            bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.DIAMOND_SWORD))\n            if (bukkitPlayer!!.inventory.itemInOffHand.type != Material.SHIELD) {\n                bukkitPlayer!!.inventory.setItemInOffHand(org.bukkit.inventory.ItemStack(Material.SHIELD))\n            }\n            bukkitPlayer!!.updateInventory()\n            if (isLegacy12) legacyImpl12?.startUsingOffhand()\n            return\n        }\n        bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.SHIELD))\n\n        val livingEntityClass = getNMSClass(\"net.minecraft.world.entity.LivingEntity\")\n\n        // Start using item (simulate blocking)\n        val interactionHandClass = getNMSClass(\"net.minecraft.world.InteractionHand\")\n        val mainHandFieldName = reflectionRemapper.remapFieldName(interactionHandClass, \"MAIN_HAND\")\n        val mainHandField = interactionHandClass.getDeclaredField(mainHandFieldName)\n        val mainHand = mainHandField.get(null)\n\n        val startUsingItemMethodName = reflectionRemapper.remapMethodName(\n            livingEntityClass,\n            \"startUsingItem\",\n            interactionHandClass\n        )\n\n        val startUsingItemMethod = checkNotNull(\n            Reflector.getMethod(livingEntityClass, startUsingItemMethodName, interactionHandClass.simpleName)\n        )\n        Reflector.invokeMethod<Any>(startUsingItemMethod, serverPlayer, mainHand)\n\n        // Manually set useItemRemaining field to simulate blocking\n        val useItemRemainingFieldName = reflectionRemapper.remapFieldName(livingEntityClass, \"useItemRemaining\")\n        val useItemRemainingField = livingEntityClass.getDeclaredField(useItemRemainingFieldName)\n        useItemRemainingField.isAccessible = true\n        useItemRemainingField.setInt(serverPlayer, 200)\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FireAspectOverdamageIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XEnchantment\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeLessThan\nimport io.kotest.matchers.ints.shouldBeGreaterThan\nimport io.kotest.matchers.ints.shouldBeLessThanOrEqual\nimport io.kotest.matchers.ints.shouldBeExactly\nimport io.kotest.matchers.shouldBe\nimport io.kotest.assertions.withClue\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.potion.PotionEffect\nimport org.bukkit.potion.PotionEffectType\nimport org.bukkit.util.Vector\nimport kotlinx.coroutines.delay\nimport java.util.concurrent.Callable\nimport kotlin.math.abs\n\n@OptIn(ExperimentalKotest::class)\nclass FireAspectOverdamageIntegrationTest : FunSpec({\n    val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n\n    extensions(MainThreadDispatcherExtension(plugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(plugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    suspend fun delayTicks(ticks: Long) {\n        delay(ticks * 50L)\n    }\n\n    val isLegacyServer = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.versionIsNewerOrEqualTo(1, 13, 0)\n\n    fun needsAttackWarmup(attacker: Player): Boolean = isLegacyServer\n\n    data class AttackSample(\n        val cancelled: Boolean,\n        val damage: Double,\n        val finalDamage: Double,\n        val noDamageTicks: Int,\n        val lastDamage: Double\n    )\n\n    data class FireTickSample(val cancelled: Boolean, val finalDamage: Double)\n\n    suspend fun waitForFireTick(samples: List<FireTickSample>, timeoutTicks: Long = 120) {\n        repeat(timeoutTicks.toInt()) {\n            if (samples.isNotEmpty()) return\n            delayTicks(1)\n        }\n        error(\"Expected a fire tick event within $timeoutTicks ticks, but none fired.\")\n    }\n\n    fun prepareWeapon(item: ItemStack) {\n        val meta = item.itemMeta ?: return\n        val speedModifier = createAttributeModifier(\n            name = \"speed\",\n            amount = 1000.0,\n            operation = AttributeModifier.Operation.ADD_NUMBER,\n            slot = EquipmentSlot.HAND\n        )\n        val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n        addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n        item.itemMeta = meta\n    }\n\n    fun equip(player: Player, item: ItemStack) {\n        prepareWeapon(item)\n        val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()\n        if (attackSpeedAttribute != null) {\n            player.getAttribute(attackSpeedAttribute)?.baseValue = 1000.0\n        }\n        if (isLegacyServer) {\n            // Legacy fake players can miss item-based attack damage modifiers until after the first swing.\n            // Force a stable baseline so overdamage tests are not dependent on attribute refresh timing.\n            val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n            val attribute = attackDamageAttribute?.let { player.getAttribute(it) }\n            if (attribute != null) {\n                attribute.baseValue = when (item.type) {\n                    Material.DIAMOND_SWORD -> 7.0\n                    else -> attribute.baseValue\n                }\n            }\n        }\n        player.inventory.setItemInMainHand(item)\n        player.updateInventory()\n    }\n\n    fun spawnPlayer(location: Location): Pair<FakePlayer, Player> {\n        val fake = FakePlayer(plugin)\n        fake.spawn(location)\n        val player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n        player.gameMode = GameMode.SURVIVAL\n        player.isInvulnerable = false\n        player.inventory.clear()\n        player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n        val playerData = getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, \"old\")\n        setPlayerData(player.uniqueId, playerData)\n        return fake to player\n    }\n\n    fun spawnVictim(location: Location): LivingEntity {\n        val world = location.world ?: error(\"World missing for victim spawn\")\n        return world.spawn(location, org.bukkit.entity.Zombie::class.java).apply {\n            maximumNoDamageTicks = 100\n            noDamageTicks = 0\n            isInvulnerable = false\n            health = maxHealth\n        }\n    }\n\n    fun prepareVictimState(victim: LivingEntity, maxHealthOverride: Double? = null) {\n        if (maxHealthOverride != null) {\n            val maxHealthAttribute = XAttribute.MAX_HEALTH.get()\n            if (maxHealthAttribute != null) {\n                victim.getAttribute(maxHealthAttribute)?.baseValue = maxHealthOverride\n            } else {\n                victim.maxHealth = maxHealthOverride\n            }\n        }\n        victim.maximumNoDamageTicks = 100\n        victim.noDamageTicks = 0\n        victim.lastDamage = 0.0\n        victim.fireTicks = 0\n        victim.isInvulnerable = false\n        victim.health = victim.maxHealth\n    }\n\n    fun ensureBurning(victim: LivingEntity, minTicks: Int = 200) {\n        if (victim.fireTicks < minTicks) {\n            victim.fireTicks = minTicks\n        }\n    }\n\n    fun requireInvulnerabilityWindow(victim: LivingEntity) {\n        val maxTicks = victim.maximumNoDamageTicks\n        if (victim.noDamageTicks.toDouble() <= maxTicks / 2.0) {\n            error(\n                \"Expected to still be inside the invulnerability window, but noDamageTicks=\" +\n                    \"${victim.noDamageTicks} maxNoDamageTicks=$maxTicks\"\n            )\n        }\n    }\n\n    fun applyProtectionArmour(entity: LivingEntity) {\n        val protection = XEnchantment.PROTECTION.get()\n        val armour = arrayOf(\n            ItemStack(Material.DIAMOND_BOOTS),\n            ItemStack(Material.DIAMOND_LEGGINGS),\n            ItemStack(Material.DIAMOND_CHESTPLATE),\n            ItemStack(Material.DIAMOND_HELMET)\n        )\n        if (protection != null) {\n            armour.forEach { it.addUnsafeEnchantment(protection, 4) }\n        }\n        if (entity is Player) {\n            entity.inventory.setArmorContents(armour)\n            return\n        }\n        entity.equipment?.armorContents = armour\n    }\n\n    fun applyFireProtectionArmour(entity: LivingEntity) {\n        val fireProtection = XEnchantment.FIRE_PROTECTION.get()\n        val armour = arrayOf(\n            ItemStack(Material.DIAMOND_BOOTS),\n            ItemStack(Material.DIAMOND_LEGGINGS),\n            ItemStack(Material.DIAMOND_CHESTPLATE),\n            ItemStack(Material.DIAMOND_HELMET)\n        )\n        if (fireProtection != null) {\n            armour.forEach { it.addUnsafeEnchantment(fireProtection, 4) }\n        }\n        if (entity is Player) {\n            entity.inventory.setArmorContents(armour)\n            return\n        }\n        entity.equipment?.armorContents = armour\n    }\n\n    fun applyFireResistance(entity: LivingEntity, durationTicks: Int = 20 * 60) {\n        entity.addPotionEffect(PotionEffect(PotionEffectType.FIRE_RESISTANCE, durationTicks, 0), true)\n    }\n\n    fun disableAiIfPossible(entity: LivingEntity) {\n        runCatching {\n            val method = entity.javaClass.methods.firstOrNull { m ->\n                m.name == \"setAI\" &&\n                    m.parameterTypes.size == 1 &&\n                    (m.parameterTypes[0] == java.lang.Boolean.TYPE || m.parameterTypes[0] == java.lang.Boolean::class.java)\n            } ?: return\n            method.invoke(entity, false)\n        }\n    }\n\n    fun stabilise(entity: Entity, location: Location) {\n        entity.fallDistance = 0.0f\n        entity.teleport(location)\n        entity.velocity = Vector(0, 0, 0)\n    }\n\n    data class BlockTypeSnapshot(val location: Location, val type: Material)\n\n    fun placeWaterColumn(world: org.bukkit.World, base: Location, height: Int = 2): List<BlockTypeSnapshot> {\n        val x = base.blockX\n        val y = base.blockY\n        val z = base.blockZ\n        val snapshots = mutableListOf<BlockTypeSnapshot>()\n        repeat(height) { dy ->\n            val block = world.getBlockAt(x, y + dy, z)\n            snapshots.add(BlockTypeSnapshot(block.location, block.type))\n            block.type = Material.WATER\n        }\n        return snapshots\n    }\n\n    fun restoreBlocks(snapshots: List<BlockTypeSnapshot>) {\n        snapshots.forEach { snap ->\n            val world = snap.location.world ?: return@forEach\n            world.getBlockAt(snap.location.blockX, snap.location.blockY, snap.location.blockZ).type = snap.type\n        }\n    }\n\n    suspend fun countSuccessfulAttacks(\n        attacker: Player,\n        victim: LivingEntity,\n        weapon: ItemStack,\n        attempts: Int,\n        tickDelay: Long\n    ): Int {\n        var count = 0\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                if (event.entity.uniqueId != victim.uniqueId) return\n                if (event.damager.uniqueId != attacker.uniqueId) return\n                if (event.cause != EntityDamageEvent.DamageCause.ENTITY_ATTACK) return\n                if (!event.isCancelled) {\n                    count++\n                }\n            }\n        }\n\n        Bukkit.getPluginManager().registerEvents(listener, plugin)\n        try {\n            equip(attacker, weapon)\n            repeat(attempts) {\n                runSync {\n                    attackCompat(attacker, victim)\n                }\n                delayTicks(tickDelay)\n            }\n        } finally {\n            HandlerList.unregisterAll(listener)\n        }\n        return count\n    }\n\n    suspend fun collectFireTickDamages(\n        victim: LivingEntity,\n        expectedCount: Int,\n        trigger: suspend () -> Unit\n    ): List<Double> {\n        val samples = mutableListOf<FireTickSample>()\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onFireTick(event: EntityDamageEvent) {\n                if (event.entity.uniqueId != victim.uniqueId) return\n                val cause = event.cause\n                if (cause != EntityDamageEvent.DamageCause.FIRE_TICK &&\n                    cause != EntityDamageEvent.DamageCause.FIRE\n                ) return\n                samples.add(FireTickSample(event.isCancelled, event.finalDamage))\n            }\n        }\n\n        Bukkit.getPluginManager().registerEvents(listener, plugin)\n        try {\n            trigger()\n            repeat(200) {\n                if (samples.size >= expectedCount) return@repeat\n                delayTicks(1)\n            }\n        } finally {\n            HandlerList.unregisterAll(listener)\n        }\n\n        if (samples.size < expectedCount) {\n            error(\"Expected $expectedCount fire tick events, got ${samples.size}\")\n        }\n\n        val nonCancelled = samples.filterNot { it.cancelled }\n        if (nonCancelled.size < expectedCount) {\n            error(\n                \"Expected $expectedCount non-cancelled fire tick events, got ${nonCancelled.size} \" +\n                    \"(cancelled=${samples.count { it.cancelled }})\"\n            )\n        }\n        return nonCancelled.take(expectedCount).map { it.finalDamage }\n    }\n\n    fun snapshotOverdamageState(\n        attacker: Player,\n        victim: LivingEntity,\n        attackSamples: List<AttackSample>,\n        fireTickSamples: List<FireTickSample>\n    ): String {\n        var snapshot = \"\"\n        runSync {\n            val meta = victim.getMetadata(\"ocm-last-damage\")\n                .joinToString(prefix = \"[\", postfix = \"]\") { m ->\n                    \"${m.owningPlugin?.name ?: \"?\"}=${runCatching { m.asDouble() }.getOrNull()}\"\n                }\n            val attackDetails = attackSamples.joinToString(prefix = \"[\", postfix = \"]\") {\n                \"{c=${it.cancelled}, dmg=${it.damage}, final=${it.finalDamage}, ndt=${it.noDamageTicks}, ld=${it.lastDamage}}\"\n            }\n            snapshot =\n                \"attacks(total=${attackSamples.size}, ok=${attackSamples.count { !it.cancelled }}, \" +\n                    \"cancelled=${attackSamples.count { it.cancelled }}, details=$attackDetails) \" +\n                    \"fireTicks(total=${fireTickSamples.size}, ok=${fireTickSamples.count { !it.cancelled }}, \" +\n                    \"cancelled=${fireTickSamples.count { it.cancelled }}) \" +\n                    \"attacker(onGround=${attacker.isOnGround}, fallDistance=${attacker.fallDistance}, \" +\n                    \"sprinting=${attacker.isSprinting}, velocity=${attacker.velocity}) \" +\n                    \"victim(noDamageTicks=${victim.noDamageTicks}, maxNoDamageTicks=${victim.maximumNoDamageTicks}, \" +\n                    \"lastDamage=${victim.lastDamage}, health=${victim.health}/${victim.maxHealth}, \" +\n                    \"fireTicks=${victim.fireTicks}, meta=$meta)\"\n        }\n        return snapshot\n    }\n\n    test(\"fire aspect does not bypass invulnerability cancellation\") {\n        if (!Reflector.versionIsNewerOrEqualTo(1, 12, 0)) return@test\n        val attackSamples = mutableListOf<AttackSample>()\n        val fireTickSamples = mutableListOf<FireTickSample>()\n        lateinit var attacker: Player\n        var victim: LivingEntity? = null\n        var fakeAttacker: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    event.damager.uniqueId == attacker.uniqueId\n                ) {\n                    attackSamples.add(\n                        AttackSample(\n                            cancelled = event.isCancelled,\n                            damage = event.damage,\n                            finalDamage = event.finalDamage,\n                            noDamageTicks = currentVictim.noDamageTicks,\n                            lastDamage = currentVictim.lastDamage\n                        )\n                    )\n                }\n            }\n\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onFireTick(event: EntityDamageEvent) {\n                val cause = event.cause\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    (cause == EntityDamageEvent.DamageCause.FIRE_TICK || cause == EntityDamageEvent.DamageCause.FIRE)\n                ) {\n                    fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(checkNotNull(victim))\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n                val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                if (fireAspect != null) {\n                    weapon.addUnsafeEnchantment(fireAspect, 2)\n                }\n                equip(attacker, weapon)\n            }\n\n            // Vanilla 1.12 scales attack damage by cooldown *before* the Bukkit damage event is fired.\n            // Fake players can start with an incomplete cooldown, so wait a few ticks to make the first hit stable.\n            if (needsAttackWarmup(attacker)) {\n                delayTicks(6)\n            }\n            runSync {\n                attackCompat(attacker, checkNotNull(victim))\n            }\n\n            delayTicks(2)\n            runSync {\n                val fireEvent = EntityDamageEvent(\n                    checkNotNull(victim),\n                    EntityDamageEvent.DamageCause.FIRE_TICK,\n                    1.0\n                )\n                Bukkit.getPluginManager().callEvent(fireEvent)\n            }\n\n            waitForFireTick(fireTickSamples, timeoutTicks = 5)\n            runSync {\n                requireInvulnerabilityWindow(checkNotNull(victim))\n            }\n\n            runSync {\n                attackCompat(attacker, checkNotNull(victim))\n            }\n\n            delayTicks(2)\n\n            val state = snapshotOverdamageState(attacker, checkNotNull(victim), attackSamples, fireTickSamples)\n            withClue(state) {\n                attackSamples.count { !it.cancelled }.shouldBeExactly(1)\n            }\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"fire tick does not clear overdamage baseline\") {\n        val attackSamples = mutableListOf<AttackSample>()\n        val fireTickSamples = mutableListOf<FireTickSample>()\n        lateinit var attacker: Player\n        var victim: LivingEntity? = null\n        var fakeAttacker: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    event.damager.uniqueId == attacker.uniqueId\n                ) {\n                    attackSamples.add(\n                        AttackSample(\n                            cancelled = event.isCancelled,\n                            damage = event.damage,\n                            finalDamage = event.finalDamage,\n                            noDamageTicks = currentVictim.noDamageTicks,\n                            lastDamage = currentVictim.lastDamage\n                        )\n                    )\n                }\n            }\n\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onFireTick(event: EntityDamageEvent) {\n                val cause = event.cause\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    (cause == EntityDamageEvent.DamageCause.FIRE_TICK || cause == EntityDamageEvent.DamageCause.FIRE)\n                ) {\n                    fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(checkNotNull(victim))\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n                equip(attacker, weapon)\n            }\n\n            if (needsAttackWarmup(attacker)) {\n                delayTicks(6)\n            }\n            runSync {\n                attackCompat(attacker, checkNotNull(victim))\n            }\n\n            delayTicks(2)\n            runSync {\n                val fireEvent = EntityDamageEvent(\n                    checkNotNull(victim),\n                    EntityDamageEvent.DamageCause.FIRE_TICK,\n                    1.0\n                )\n                Bukkit.getPluginManager().callEvent(fireEvent)\n            }\n\n            waitForFireTick(fireTickSamples, timeoutTicks = 5)\n            runSync {\n                requireInvulnerabilityWindow(checkNotNull(victim))\n            }\n\n            runSync {\n                attackCompat(attacker, checkNotNull(victim))\n            }\n\n            delayTicks(2)\n\n            val state = snapshotOverdamageState(attacker, checkNotNull(victim), attackSamples, fireTickSamples)\n            withClue(state) {\n                attackSamples.count { !it.cancelled }.shouldBeExactly(1)\n            }\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"fire aspect afterburn matches environmental fire tick damage (zombie)\") {\n        lateinit var attacker: Player\n        lateinit var victim: LivingEntity\n        var fakeAttacker: FakePlayer? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(victim)\n            }\n\n            val environmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            val afterburn = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) {\n                        weapon.addUnsafeEnchantment(fireAspect, 2)\n                    }\n                    equip(attacker, weapon)\n                    attackCompat(attacker, victim)\n                    ensureBurning(victim, minTicks = 200)\n                }\n                delayTicks(12)\n            }\n\n            val environmentalAvg = environmental.average()\n            val afterburnAvg = afterburn.average()\n            abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim.remove()\n            }\n        }\n    }\n\n    test(\"fire aspect afterburn matches environmental fire tick damage (player)\") {\n        if (isLegacyServer) return@test\n\n        lateinit var attacker: Player\n        lateinit var victim: Player\n        var fakeAttacker: FakePlayer? = null\n        var fakeVictim: FakePlayer? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n\n                val (fakeV, playerV) = spawnPlayer(victimLocation)\n                fakeVictim = fakeV\n                victim = playerV\n                prepareVictimState(victim)\n            }\n\n            val environmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            val afterburn = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) {\n                        weapon.addUnsafeEnchantment(fireAspect, 2)\n                    }\n                    equip(attacker, weapon)\n                    attackCompat(attacker, victim)\n                    ensureBurning(victim, minTicks = 200)\n                }\n                delayTicks(12)\n            }\n\n            val environmentalAvg = environmental.average()\n            val afterburnAvg = afterburn.average()\n            abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                fakeVictim?.removePlayer()\n            }\n        }\n    }\n\n    test(\"fire aspect afterburn matches environmental fire tick damage with protection armour (zombie)\") {\n        // Use an armoured mob victim to keep fire tick sampling stable across versions.\n        lateinit var attacker: Player\n        lateinit var victim: LivingEntity\n        var fakeAttacker: FakePlayer? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(victim)\n                applyProtectionArmour(victim)\n            }\n\n            val environmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            val afterburn = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) {\n                        weapon.addUnsafeEnchantment(fireAspect, 2)\n                    }\n                    equip(attacker, weapon)\n                    attackCompat(attacker, victim)\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n                delayTicks(12)\n            }\n\n            val environmentalAvg = environmental.average()\n            val afterburnAvg = afterburn.average()\n            abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim.remove()\n            }\n        }\n    }\n\n    test(\"fire aspect afterburn matches environmental fire tick damage with protection armour (player)\") {\n        if (isLegacyServer) return@test\n\n        lateinit var attacker: Player\n        lateinit var victim: Player\n        var fakeAttacker: FakePlayer? = null\n        var fakeVictim: FakePlayer? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n\n                val (fakeV, playerV) = spawnPlayer(victimLocation)\n                fakeVictim = fakeV\n                victim = playerV\n                prepareVictimState(victim)\n                applyProtectionArmour(victim)\n            }\n\n            val environmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            val afterburn = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) {\n                        weapon.addUnsafeEnchantment(fireAspect, 2)\n                    }\n                    equip(attacker, weapon)\n                    attackCompat(attacker, victim)\n                    ensureBurning(victim, minTicks = 200)\n                }\n                delayTicks(12)\n            }\n\n            val environmentalAvg = environmental.average()\n            val afterburnAvg = afterburn.average()\n            abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                fakeVictim?.removePlayer()\n            }\n        }\n    }\n\n    test(\"fire aspect does not increase successful hits during rapid clicking\") {\n        lateinit var attacker: Player\n        lateinit var victim: LivingEntity\n        var fakeAttacker: FakePlayer? = null\n        var fireTickCount = 0\n        val fireTickSamples = mutableListOf<FireTickSample>()\n        val fireTickListener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onFireTick(event: EntityDamageEvent) {\n                if (event.entity.uniqueId != victim.uniqueId) return\n                val cause = event.cause\n                if (cause == EntityDamageEvent.DamageCause.FIRE ||\n                    cause == EntityDamageEvent.DamageCause.FIRE_TICK\n                ) {\n                    fireTickCount++\n                    fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(victim, maxHealthOverride = 200.0)\n            }\n\n            val baselineWeapon = ItemStack(Material.DIAMOND_SWORD)\n            val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->\n                val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                if (fireAspect != null) {\n                    item.addUnsafeEnchantment(fireAspect, 2)\n                }\n            }\n\n            Bukkit.getPluginManager().registerEvents(fireTickListener, plugin)\n\n            runSync {\n                prepareVictimState(victim, maxHealthOverride = 200.0)\n            }\n            val baselineHits = countSuccessfulAttacks(attacker, victim, baselineWeapon, attempts = 30, tickDelay = 1)\n\n            runSync {\n                prepareVictimState(victim, maxHealthOverride = 200.0)\n            }\n            val fireHits = countSuccessfulAttacks(attacker, victim, fireWeapon, attempts = 5, tickDelay = 1).also {\n                runSync { ensureBurning(victim, minTicks = 200) }\n            }\n            waitForFireTick(fireTickSamples)\n            val remainingHits = countSuccessfulAttacks(attacker, victim, fireWeapon, attempts = 25, tickDelay = 1)\n\n            val totalFireHits = fireHits + remainingHits\n            fireTickCount.shouldBeGreaterThan(0)\n            abs(totalFireHits - baselineHits).shouldBeLessThanOrEqual(2)\n        } finally {\n            HandlerList.unregisterAll(fireTickListener)\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim.remove()\n            }\n        }\n    }\n\n    test(\"fire aspect does not increase successful hits for fire resistant or fire immune victims\") {\n        lateinit var attacker: Player\n        var fakeAttacker: FakePlayer? = null\n        var fakeVictim: FakePlayer? = null\n        var blaze: LivingEntity? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n\n                val (fakeV, playerV) = spawnPlayer(victimLocation)\n                fakeVictim = fakeV\n                prepareVictimState(playerV, maxHealthOverride = 200.0)\n                applyFireResistance(playerV)\n            }\n\n            val baselineWeapon = ItemStack(Material.DIAMOND_SWORD)\n            val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->\n                val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                if (fireAspect != null) item.addUnsafeEnchantment(fireAspect, 2)\n            }\n\n            val fireResBaseline = countSuccessfulAttacks(\n                attacker = attacker,\n                victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid)),\n                weapon = baselineWeapon,\n                attempts = 30,\n                tickDelay = 1\n            )\n            runSync {\n                val victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid))\n                prepareVictimState(victim, maxHealthOverride = 200.0)\n                applyFireResistance(victim)\n            }\n            val fireResWithFireAspect = countSuccessfulAttacks(\n                attacker = attacker,\n                victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid)),\n                weapon = fireWeapon,\n                attempts = 30,\n                tickDelay = 1\n            )\n\n            abs(fireResWithFireAspect - fireResBaseline).shouldBeLessThanOrEqual(2)\n\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val blazeLocation = Location(world, 1.2, 100.0, 2.0)\n                blaze = world.spawn(blazeLocation, org.bukkit.entity.Blaze::class.java).apply {\n                    maximumNoDamageTicks = 100\n                    noDamageTicks = 0\n                    isInvulnerable = false\n                    health = maxHealth\n                }\n                disableAiIfPossible(checkNotNull(blaze))\n                stabilise(checkNotNull(blaze), blazeLocation)\n            }\n\n            val blazeBaseline = countSuccessfulAttacks(\n                attacker = attacker,\n                victim = checkNotNull(blaze),\n                weapon = baselineWeapon,\n                attempts = 30,\n                tickDelay = 1\n            )\n            runSync {\n                prepareVictimState(checkNotNull(blaze), maxHealthOverride = 200.0)\n                stabilise(checkNotNull(blaze), checkNotNull(blaze).location)\n            }\n            val blazeWithFireAspect = countSuccessfulAttacks(\n                attacker = attacker,\n                victim = checkNotNull(blaze),\n                weapon = fireWeapon,\n                attempts = 30,\n                tickDelay = 1\n            )\n\n            abs(blazeWithFireAspect - blazeBaseline).shouldBeLessThanOrEqual(2)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                fakeVictim?.removePlayer()\n                blaze?.remove()\n            }\n        }\n    }\n\n    test(\"fire protection reduces fire tick damage and afterburn matches environmental\") {\n        lateinit var attacker: Player\n        lateinit var victim: LivingEntity\n        var fakeAttacker: FakePlayer? = null\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnPlayer(attackerLocation)\n                fakeAttacker = fakeA\n                attacker = playerA\n\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(victim)\n            }\n\n            runSync { applyProtectionArmour(victim) }\n            val protEnvironmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            runSync {\n                prepareVictimState(victim)\n                applyFireProtectionArmour(victim)\n            }\n            val fireProtEnvironmental = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    ensureBurning(victim, minTicks = 200)\n                }\n            }\n\n            val tolerance = if (Reflector.versionIsNewerOrEqualTo(1, 20, 0)) 0.35 else 0.5\n            fireProtEnvironmental.average().shouldBeLessThan(protEnvironmental.average() + tolerance + 1e-6)\n\n            runSync {\n                prepareVictimState(victim)\n                applyFireProtectionArmour(victim)\n            }\n            val afterburn = collectFireTickDamages(victim, 1) {\n                runSync {\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) {\n                        weapon.addUnsafeEnchantment(fireAspect, 2)\n                    }\n                    equip(attacker, weapon)\n                    attackCompat(attacker, victim)\n                    ensureBurning(victim, minTicks = 200)\n                }\n                delayTicks(12)\n            }\n\n            abs(afterburn.average() - fireProtEnvironmental.average()).shouldBeLessThan(tolerance)\n        } finally {\n            runSync {\n                fakeAttacker?.removePlayer()\n                victim.remove()\n            }\n        }\n    }\n\n    test(\"water extinguishes fire without fire tick damage\") {\n        lateinit var victim: Player\n        var fakeVictim: FakePlayer? = null\n        var blockSnapshots: List<BlockTypeSnapshot> = emptyList()\n        val samples = mutableListOf<FireTickSample>()\n\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onFireTick(event: EntityDamageEvent) {\n                if (event.entity.uniqueId != victim.uniqueId) return\n                val cause = event.cause\n                if (cause != EntityDamageEvent.DamageCause.FIRE_TICK &&\n                    cause != EntityDamageEvent.DamageCause.FIRE\n                ) return\n                samples.add(FireTickSample(event.isCancelled, event.finalDamage))\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val victimLocation = Location(world, 0.5, 100.0, 0.5)\n                val (fakeV, playerV) = spawnPlayer(victimLocation)\n                fakeVictim = fakeV\n                victim = playerV\n                prepareVictimState(victim)\n                blockSnapshots = placeWaterColumn(world, victimLocation, height = 2)\n                stabilise(victim, victimLocation)\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n                ensureBurning(victim, minTicks = 200)\n            }\n\n            // Allow the initial tick or two to settle (some versions may still emit an early fire damage event).\n            delayTicks(2)\n            runSync { samples.clear() }\n            delayTicks(20)\n\n            val hadPositiveDamage = samples.any { !it.cancelled && it.finalDamage > 0.0 }\n            hadPositiveDamage.shouldBe(false)\n\n            runSync {\n                victim.fireTicks.shouldBeLessThanOrEqual(1)\n            }\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                restoreBlocks(blockSnapshots)\n                fakeVictim?.removePlayer()\n            }\n        }\n    }\n\n    test(\"fire tick does not let a second attacker bypass invulnerability\") {\n        lateinit var attackerA: Player\n        lateinit var attackerB: Player\n        lateinit var victim: LivingEntity\n        var fakeA: FakePlayer? = null\n        var fakeB: FakePlayer? = null\n\n        val samples = mutableListOf<AttackSample>()\n        val listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                if (event.entity.uniqueId != victim.uniqueId) return\n                if (event.cause != EntityDamageEvent.DamageCause.ENTITY_ATTACK) return\n                if (event.damager.uniqueId != attackerA.uniqueId &&\n                    event.damager.uniqueId != attackerB.uniqueId\n                ) return\n                samples.add(\n                    AttackSample(\n                        cancelled = event.isCancelled,\n                        damage = event.damage,\n                        finalDamage = event.finalDamage,\n                        noDamageTicks = victim.noDamageTicks,\n                        lastDamage = victim.lastDamage\n                    )\n                )\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val aLocation = Location(world, 0.0, 100.0, 0.0)\n                val bLocation = Location(world, 0.0, 100.0, 2.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fa, pa) = spawnPlayer(aLocation)\n                fakeA = fa\n                attackerA = pa\n\n                val (fb, pb) = spawnPlayer(bLocation)\n                fakeB = fb\n                attackerB = pb\n\n                victim = spawnVictim(victimLocation)\n                prepareVictimState(victim)\n\n                val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->\n                    val fireAspect = XEnchantment.FIRE_ASPECT.get()\n                    if (fireAspect != null) item.addUnsafeEnchantment(fireAspect, 2)\n                }\n                equip(attackerA, fireWeapon)\n                equip(attackerB, ItemStack(Material.DIAMOND_SWORD))\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n            }\n\n            if (needsAttackWarmup(attackerA)) {\n                delayTicks(6)\n            }\n            runSync { attackCompat(attackerA, victim) }\n\n            delayTicks(2)\n            runSync {\n                val fireEvent = EntityDamageEvent(victim, EntityDamageEvent.DamageCause.FIRE_TICK, 1.0)\n                Bukkit.getPluginManager().callEvent(fireEvent)\n            }\n            runSync { requireInvulnerabilityWindow(victim) }\n\n            if (needsAttackWarmup(attackerB)) {\n                delayTicks(6)\n            }\n            runSync { attackCompat(attackerB, victim) }\n\n            delayTicks(2)\n\n            samples.count { !it.cancelled }.shouldBeExactly(1)\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fakeA?.removePlayer()\n                fakeB?.removePlayer()\n                victim.remove()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FishingRodVelocityIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeLessThan\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleFishingRodVelocity\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.FishHook\nimport org.bukkit.entity.Player\nimport org.bukkit.event.player.PlayerFishEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.util.Vector\nimport java.util.Random\nimport java.util.UUID\nimport java.util.concurrent.Callable\nimport java.lang.reflect.InvocationHandler\nimport java.lang.reflect.Proxy\nimport kotlin.math.cos\nimport kotlin.math.abs\nimport kotlin.math.sin\nimport kotlin.math.sqrt\n\n@OptIn(ExperimentalKotest::class)\nclass FishingRodVelocityIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleFishingRodVelocity>()\n        .firstOrNull() ?: error(\"ModuleFishingRodVelocity not registered\")\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n            action()\n        }).get()\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n    }\n\n    fun setModuleRandomSeed(seed: Long) {\n        val field = module.javaClass.getDeclaredField(\"random\")\n        field.isAccessible = true\n        field.set(module, Random(seed))\n    }\n\n    fun assertVectorClose(actual: Vector, expected: Vector, tolerance: Double) {\n        abs(actual.x - expected.x) shouldBeLessThan tolerance\n        abs(actual.y - expected.y) shouldBeLessThan tolerance\n        abs(actual.z - expected.z) shouldBeLessThan tolerance\n    }\n\n    fun createFishEvent(player: Player, hook: FishHook, state: PlayerFishEvent.State): PlayerFishEvent {\n        val ctors = PlayerFishEvent::class.java.constructors\n        for (ctor in ctors) {\n            val paramTypes = ctor.parameterTypes\n            val hasFishHookParam = paramTypes.any { FishHook::class.java.isAssignableFrom(it) }\n            val args = arrayOfNulls<Any>(paramTypes.size)\n            var ok = true\n            var hookAssigned = false\n\n            for (i in paramTypes.indices) {\n                val t = paramTypes[i]\n                args[i] = when {\n                    Player::class.java.isAssignableFrom(t) -> player\n                    FishHook::class.java.isAssignableFrom(t) -> {\n                        hookAssigned = true\n                        hook\n                    }\n                    Entity::class.java.isAssignableFrom(t) -> {\n                        if (hasFishHookParam) {\n                            // Treat as \"caught\" entity slot in modern signatures\n                            null\n                        } else if (!hookAssigned) {\n                            // Legacy signatures sometimes use Entity for the hook\n                            hookAssigned = true\n                            hook\n                        } else {\n                            null\n                        }\n                    }\n                    PlayerFishEvent.State::class.java.isAssignableFrom(t) -> state\n                    t == Int::class.javaPrimitiveType -> 0\n                    t == Boolean::class.javaPrimitiveType -> false\n                    t.isEnum && t.name.endsWith(\"EquipmentSlot\") -> {\n                        // Prefer main-hand if present (modern signature)\n                        runCatching { java.lang.Enum.valueOf(t as Class<out Enum<*>>, \"HAND\") }.getOrNull()\n                    }\n                    ItemStack::class.java.isAssignableFrom(t) -> ItemStack(Material.FISHING_ROD)\n                    else -> null\n                }\n\n                // If we failed to provide a value for a primitive, this ctor won't work\n                if (args[i] == null && t.isPrimitive) {\n                    ok = false\n                    break\n                }\n            }\n\n            if (!ok) continue\n            try {\n                @Suppress(\"UNCHECKED_CAST\")\n                val event = ctor.newInstance(*args) as PlayerFishEvent\n\n                // Validate that the event reports the expected hook; legacy signatures vary.\n                val hookObj = runCatching {\n                    PlayerFishEvent::class.java.getMethod(\"getHook\").invoke(event)\n                }.getOrNull()\n                val hookFromEvent = hookObj as? FishHook ?: continue\n                if (hookFromEvent.uniqueId != hook.uniqueId) continue\n\n                return event\n            } catch (_: Throwable) {\n                // Try next\n            }\n        }\n        error(\"No compatible PlayerFishEvent constructor found for this server version\")\n    }\n\n    fun createFakeHook(player: Player): FishHook {\n        val id = UUID.randomUUID()\n        var velocity = Vector(0.0, 0.0, 0.0)\n        val handler = InvocationHandler { _, method, args ->\n            when (method.name) {\n                \"getUniqueId\" -> return@InvocationHandler id\n                \"getVelocity\" -> return@InvocationHandler velocity\n                \"setVelocity\" -> {\n                    velocity = (args?.get(0) as? Vector) ?: velocity\n                    return@InvocationHandler null\n                }\n                \"isValid\" -> return@InvocationHandler true\n                \"remove\" -> return@InvocationHandler null\n                \"getWorld\" -> return@InvocationHandler player.world\n                \"getLocation\" -> return@InvocationHandler player.location.clone()\n            }\n\n            return@InvocationHandler when (method.returnType) {\n                java.lang.Boolean.TYPE -> false\n                java.lang.Integer.TYPE -> 0\n                java.lang.Long.TYPE -> 0L\n                java.lang.Float.TYPE -> 0f\n                java.lang.Double.TYPE -> 0.0\n                java.lang.Void.TYPE -> null\n                else -> null\n            }\n        }\n\n        val interfaces = mutableListOf<Class<*>>(FishHook::class.java)\n        runCatching { Class.forName(\"org.bukkit.entity.Fish\") }\n            .getOrNull()\n            ?.takeIf { it.isInterface }\n            ?.let { interfaces.add(it) }\n        return Proxy.newProxyInstance(\n            FishHook::class.java.classLoader,\n            interfaces.toTypedArray(),\n            handler\n        ) as FishHook\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getWorld(\"world\") ?: error(\"world not loaded\")\n            val location = Location(world, 0.0, 120.0, 0.0, 45f, 10f)\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n            player = Bukkit.getPlayer(fakePlayer.uuid) ?: error(\"Player not found\")\n            setModeset(player, \"old\")\n            module.reload()\n        }\n    }\n\n    afterSpec {\n        runSync { fakePlayer.removePlayer() }\n    }\n\n    beforeTest {\n        runSync {\n            setModeset(player, \"old\")\n            module.reload()\n            player.inventory.setItemInMainHand(ItemStack(Material.FISHING_ROD))\n        }\n    }\n\n    test(\"sets hook velocity to the 1.8 formula (deterministic random seed)\") {\n        val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {\n            runSync { player.launchProjectile(FishHook::class.java) }\n        } else {\n            createFakeHook(player)\n        }\n        try {\n            runSync {\n                // Keep everything in a single main-thread slice so hook physics cannot tick between steps (legacy servers are sensitive).\n                setModuleRandomSeed(0)\n\n                val event = createFishEvent(player, hook, PlayerFishEvent.State.FISHING)\n                module.onFishEvent(event)\n\n                // Mirror the module's float-heavy computation so Java 8/legacy servers match precisely.\n                val yaw = player.location.yaw.toDouble()\n                val pitch = player.location.pitch.toDouble()\n\n                val oldMaxVelocity = 0.4f.toDouble()\n                val piF = Math.PI.toFloat().toDouble()\n                val degF = 180.0f.toDouble()\n\n                var vx = -sin(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity\n                var vz = cos(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity\n                var vy = -sin(pitch / degF * piF) * oldMaxVelocity\n\n                val oldVelocityMultiplier = 1.5\n                val vectorLength = sqrt(vx * vx + vy * vy + vz * vz).toFloat().toDouble()\n                vx /= vectorLength\n                vy /= vectorLength\n                vz /= vectorLength\n\n                val rng = Random(0)\n                vx += rng.nextGaussian() * 0.007499999832361937\n                vy += rng.nextGaussian() * 0.007499999832361937\n                vz += rng.nextGaussian() * 0.007499999832361937\n\n                vx *= oldVelocityMultiplier\n                vy *= oldVelocityMultiplier\n                vz *= oldVelocityMultiplier\n\n                val expected = Vector(vx, vy, vz)\n                val actual = hook.velocity\n                assertVectorClose(actual, expected, 1e-6)\n            }\n        } finally {\n            if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {\n                runSync { hook.remove() }\n            }\n        }\n    }\n\n    test(\"does not modify hook velocity for non-FISHING states\") {\n        val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {\n            runSync { player.launchProjectile(FishHook::class.java) }\n        } else {\n            createFakeHook(player)\n        }\n        try {\n            val nonFishing = PlayerFishEvent.State.values().firstOrNull { it != PlayerFishEvent.State.FISHING }\n                ?: error(\"No non-FISHING PlayerFishEvent state available\")\n\n            runSync {\n                val original = Vector(0.123, 0.456, -0.789)\n                hook.velocity = original\n                val baseline = hook.velocity\n\n                val event = createFishEvent(player, hook, nonFishing)\n                module.onFishEvent(event)\n\n                val actual = hook.velocity\n                assertVectorClose(actual, baseline, 1e-8)\n            }\n        } finally {\n            if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {\n                runSync { hook.remove() }\n            }\n        }\n    }\n\n    test(\"1.14+ applies an extra gravity tick when the hook is not in water\") {\n        if (!Reflector.versionIsNewerOrEqualTo(1, 14, 0)) return@test\n\n        val hookBaseline = runSync { player.launchProjectile(FishHook::class.java) }\n        val hookWithModule = runSync { player.launchProjectile(FishHook::class.java) }\n        try {\n            runSync {\n                // Keep both hooks colocated so vanilla physics is as close as possible\n                hookWithModule.teleport(hookBaseline.location)\n\n                // Schedule the module's per-tick gravity adjustment for hookWithModule\n                val event = createFishEvent(player, hookWithModule, PlayerFishEvent.State.FISHING)\n                Bukkit.getPluginManager().callEvent(event)\n\n                // Force the same starting velocity for both so we can compare deltas\n                val start = Vector(0.0, 0.2, 0.0)\n                hookBaseline.velocity = start\n                hookWithModule.velocity = start\n            }\n\n            // Wait for at least one tick of the module's gravity adjustment\n            delay(3 * 50L)\n\n            val yBaseline = runSync { hookBaseline.velocity.y }\n            val yWithModule = runSync { hookWithModule.velocity.y }\n\n            // Module subtracts an extra 0.01 from Y each tick (when not in water),\n            // so it should be noticeably more negative than the baseline hook.\n            (yWithModule < yBaseline - 0.005) shouldBe true\n        } finally {\n            runSync {\n                hookBaseline.remove()\n                hookWithModule.remove()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/GoldenAppleIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XMaterial\nimport com.cryptomorin.xseries.XPotion\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.ints.shouldBeGreaterThanOrEqual\nimport io.kotest.matchers.ints.shouldBeLessThanOrEqual\nimport io.kotest.matchers.shouldBe\nimport io.kotest.matchers.shouldNotBe\nimport io.kotest.matchers.longs.shouldBeBetween\nimport kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.suspendCancellableCoroutine\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.Attribute\nimport org.bukkit.entity.Player\nimport org.bukkit.event.inventory.PrepareItemCraftEvent\nimport org.bukkit.event.player.PlayerItemConsumeEvent\nimport org.bukkit.inventory.CraftingInventory\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.potion.PotionEffect\nimport org.bukkit.potion.PotionEffectType\nimport kotlin.coroutines.resume\nimport kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat\n\n@OptIn(ExperimentalKotest::class)\nclass GoldenAppleIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleGoldenApple.getInstance()\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n        val oldPotionEffects = ocm.config.getBoolean(\"old-golden-apples.old-potion-effects\")\n        val normalCooldown = ocm.config.getLong(\"old-golden-apples.cooldown.normal\")\n        val enchantedCooldown = ocm.config.getLong(\"old-golden-apples.cooldown.enchanted\")\n        val sharedCooldown = ocm.config.getBoolean(\"old-golden-apples.cooldown.is-shared\")\n        val crafting = ocm.config.getBoolean(\"old-golden-apples.enchanted-golden-apple-crafting\")\n        val noConflict = ocm.config.getBoolean(\"old-golden-apples.no-conflict-mode\")\n\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"old-golden-apples.old-potion-effects\", oldPotionEffects)\n            ocm.config.set(\"old-golden-apples.cooldown.normal\", normalCooldown)\n            ocm.config.set(\"old-golden-apples.cooldown.enchanted\", enchantedCooldown)\n            ocm.config.set(\"old-golden-apples.cooldown.is-shared\", sharedCooldown)\n            ocm.config.set(\"old-golden-apples.enchanted-golden-apple-crafting\", crafting)\n            ocm.config.set(\"old-golden-apples.no-conflict-mode\", noConflict)\n            module.reload()\n            ModuleLoader.toggleModules()\n        }\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin) {\n                action()\n                null\n            }.get()\n        }\n    }\n\n    fun setModeset(modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n    }\n\n    fun callConsume(item: ItemStack): PlayerItemConsumeEvent {\n        val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 3 &&\n                Player::class.java.isAssignableFrom(params[0]) &&\n                ItemStack::class.java.isAssignableFrom(params[1]) &&\n                EquipmentSlot::class.java.isAssignableFrom(params[2])\n        }\n        val event = if (ctor != null) {\n            ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent\n        } else {\n            PlayerItemConsumeEvent(player, item)\n        }\n        Bukkit.getPluginManager().callEvent(event)\n        return event\n    }\n\n    fun prepareCraftResult(result: ItemStack): PrepareItemCraftEvent {\n        val openWorkbench = player.javaClass.getMethod(\n            \"openWorkbench\",\n            Location::class.java,\n            Boolean::class.javaPrimitiveType\n        )\n        val viewObj = openWorkbench.invoke(player, null, true) ?: error(\"Workbench view was null\")\n        val getTopInventory = viewObj.javaClass.getMethod(\"getTopInventory\")\n        val inventory = getTopInventory.invoke(viewObj) as CraftingInventory\n        inventory.result = result\n\n        val ctor = PrepareItemCraftEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 3 &&\n                CraftingInventory::class.java.isAssignableFrom(params[0]) &&\n                params[2] == Boolean::class.javaPrimitiveType\n        } ?: error(\"PrepareItemCraftEvent constructor not found\")\n        return ctor.newInstance(inventory, viewObj, false) as PrepareItemCraftEvent\n    }\n\n    fun assertDuration(effect: PotionEffect?, expectedTicks: Int) {\n        effect.shouldNotBe(null)\n        val duration = effect!!.duration\n        duration.shouldBeGreaterThanOrEqual(expectedTicks - 10)\n        duration.shouldBeLessThanOrEqual(expectedTicks)\n    }\n\n    suspend fun waitForEffects(ticks: Long = 2L) {\n        suspendCancellableCoroutine { continuation ->\n            Bukkit.getScheduler().runTaskLater(testPlugin, Runnable {\n                if (continuation.isActive) {\n                    continuation.resume(Unit)\n                }\n            }, ticks)\n        }\n    }\n\n    fun maxHealthAttribute(): Attribute {\n        return XAttribute.MAX_HEALTH.get() ?: error(\"Max health attribute not available\")\n    }\n\n    fun enchantedAppleItem(): ItemStack {\n        return XMaterial.ENCHANTED_GOLDEN_APPLE.parseItem()\n            ?: error(\"Enchanted golden apple item not available\")\n    }\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val location = Location(world, 0.0, 100.0, 0.0)\n\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n            player.gameMode = GameMode.SURVIVAL\n            player.maximumNoDamageTicks = 20\n            player.noDamageTicks = 0\n            player.isInvulnerable = false\n            player.isOp = true\n            setModeset(\"old\")\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakePlayer.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            player.inventory.clear()\n            player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n            player.health = player.getAttribute(maxHealthAttribute())!!.value\n            player.foodLevel = 20\n            setModeset(\"old\")\n            module.reload()\n        }\n    }\n\n    context(\"Potion Effects\") {\n        test(\"golden apple applies configured effects\") {\n            player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n            callConsume(player.inventory.itemInMainHand)\n            waitForEffects()\n\n            val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)\n            val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)\n\n            assertDuration(regeneration, 5 * 20)\n            regeneration?.amplifier shouldBe 1\n            assertDuration(absorption, 120 * 20)\n            absorption?.amplifier shouldBe 0\n        }\n\n        test(\"enchanted golden apple applies configured effects\") {\n            player.inventory.setItemInMainHand(enchantedAppleItem())\n            callConsume(player.inventory.itemInMainHand)\n            waitForEffects()\n\n            val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)\n            val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)\n            val resistance = player.getPotionEffectCompat(XPotion.RESISTANCE.get()!!)\n            val fireResistance = player.getPotionEffectCompat(PotionEffectType.FIRE_RESISTANCE)\n\n            assertDuration(regeneration, 30 * 20)\n            regeneration?.amplifier shouldBe 4\n            assertDuration(absorption, 120 * 20)\n            absorption?.amplifier shouldBe 0\n            assertDuration(resistance, 300 * 20)\n            resistance?.amplifier shouldBe 0\n            assertDuration(fireResistance, 300 * 20)\n            fireResistance?.amplifier shouldBe 0\n        }\n\n        test(\"higher amplifier replaces existing effect\") {\n            player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 0))\n            player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n\n            callConsume(player.inventory.itemInMainHand)\n            waitForEffects()\n\n            val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)\n            regeneration?.amplifier shouldBe 1\n            assertDuration(regeneration, 5 * 20)\n        }\n\n        test(\"same amplifier with longer duration refreshes effect\") {\n            player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 50, 1))\n            player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n\n            callConsume(player.inventory.itemInMainHand)\n            waitForEffects()\n\n            val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)\n            regeneration?.amplifier shouldBe 1\n            assertDuration(regeneration, 5 * 20)\n        }\n\n        test(\"lower amplifier does not override existing effect\") {\n            player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 3))\n            player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n\n            callConsume(player.inventory.itemInMainHand)\n            waitForEffects()\n\n            val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)\n            regeneration?.amplifier shouldBe 3\n        }\n\n        test(\"old-potion-effects disabled leaves effects unchanged\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.old-potion-effects\", false)\n                module.reload()\n\n                player.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 200, 0))\n                player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n                callConsume(player.inventory.itemInMainHand)\n                waitForEffects()\n\n                player.getPotionEffectCompat(PotionEffectType.SPEED).shouldNotBe(null)\n                player.getPotionEffectCompat(PotionEffectType.REGENERATION).shouldBe(null)\n                player.getPotionEffectCompat(PotionEffectType.ABSORPTION).shouldBe(null)\n            }\n        }\n    }\n\n    context(\"Cooldowns\") {\n        test(\"repeated consumption is blocked by cooldown\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.cooldown.normal\", 60)\n                ocm.config.set(\"old-golden-apples.cooldown.enchanted\", 0)\n                ocm.config.set(\"old-golden-apples.cooldown.is-shared\", false)\n                module.reload()\n\n                player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n                val first = callConsume(player.inventory.itemInMainHand)\n                first.isCancelled shouldBe false\n\n                val second = callConsume(player.inventory.itemInMainHand)\n                second.isCancelled shouldBe true\n            }\n        }\n\n        test(\"shared cooldown blocks other apple type\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.cooldown.normal\", 60)\n                ocm.config.set(\"old-golden-apples.cooldown.enchanted\", 60)\n                ocm.config.set(\"old-golden-apples.cooldown.is-shared\", true)\n                module.reload()\n\n                player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n                callConsume(player.inventory.itemInMainHand)\n\n                player.inventory.setItemInMainHand(enchantedAppleItem())\n                val enchantedEvent = callConsume(player.inventory.itemInMainHand)\n                enchantedEvent.isCancelled shouldBe true\n            }\n        }\n\n        test(\"separate cooldown allows other apple type\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.cooldown.normal\", 60)\n                ocm.config.set(\"old-golden-apples.cooldown.enchanted\", 60)\n                ocm.config.set(\"old-golden-apples.cooldown.is-shared\", false)\n                module.reload()\n\n                player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n                callConsume(player.inventory.itemInMainHand)\n\n                player.inventory.setItemInMainHand(enchantedAppleItem())\n                val enchantedEvent = callConsume(player.inventory.itemInMainHand)\n                enchantedEvent.isCancelled shouldBe false\n            }\n        }\n\n        test(\"cooldown getters return remaining seconds\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.cooldown.normal\", 5)\n                ocm.config.set(\"old-golden-apples.cooldown.enchanted\", 5)\n                ocm.config.set(\"old-golden-apples.cooldown.is-shared\", false)\n                module.reload()\n\n                player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))\n                callConsume(player.inventory.itemInMainHand)\n                module.getGappleCooldown(player.uniqueId).shouldBeBetween(1, 5)\n\n                player.inventory.setItemInMainHand(enchantedAppleItem())\n                callConsume(player.inventory.itemInMainHand)\n                module.getNappleCooldown(player.uniqueId).shouldBeBetween(1, 5)\n            }\n        }\n    }\n\n    context(\"Crafting\") {\n        test(\"enchanted golden apple crafting is blocked when disabled\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.enchanted-golden-apple-crafting\", false)\n                ocm.config.set(\"old-golden-apples.no-conflict-mode\", false)\n                module.reload()\n\n                val event = prepareCraftResult(enchantedAppleItem())\n                Bukkit.getPluginManager().callEvent(event)\n                event.inventory.result.shouldBe(null)\n                player.closeInventory()\n            }\n        }\n\n        test(\"no-conflict-mode preserves crafting result\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.enchanted-golden-apple-crafting\", false)\n                ocm.config.set(\"old-golden-apples.no-conflict-mode\", true)\n                module.reload()\n\n                val event = prepareCraftResult(enchantedAppleItem())\n                Bukkit.getPluginManager().callEvent(event)\n                event.inventory.result.shouldNotBe(null)\n                player.closeInventory()\n            }\n        }\n\n        test(\"unknown modeset disables crafting\") {\n            withConfig {\n                ocm.config.set(\"old-golden-apples.enchanted-golden-apple-crafting\", true)\n                ocm.config.set(\"old-golden-apples.no-conflict-mode\", false)\n                module.reload()\n                setModeset(\"missing\")\n\n                val event = prepareCraftResult(enchantedAppleItem())\n                Bukkit.getPluginManager().callEvent(event)\n                event.inventory.result.shouldBe(null)\n                player.closeInventory()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InGameTester.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\n/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XEnchantment\nimport com.cryptomorin.xseries.XPotion\nimport kernitus.plugin.OldCombatMechanics.TesterUtils.assertEquals\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.getOldSharpnessDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.isCriticalHit1_8\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils.getDamageAfterArmour1_8\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.command.CommandSender\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.potion.PotionEffect\nimport java.util.*\nimport java.util.function.Consumer\nimport kotlin.math.max\n\nclass InGameTester(private val plugin: JavaPlugin) {\n    private var tally: Tally? = null\n    private var sender: CommandSender? = null\n    private lateinit var attacker: Player\n    private lateinit var defender: Player\n    private lateinit var fakeAttacker: FakePlayer\n    private lateinit var fakeDefender: FakePlayer\n    private val testQueue: Queue<OCMTest> = ArrayDeque()\n\n    /**\n     * Perform all tests using the two specified players\n     */\n    fun performTests(sender: CommandSender?, location: Location) {\n        plugin.logger.info(\"PERFORMING THE TESTS\")\n        this.sender = sender\n        fakeAttacker = FakePlayer(plugin)\n        plugin.logger.info(\"FAKE\")\n        fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))\n        plugin.logger.info(\"FAKE2\")\n        fakeDefender = FakePlayer(plugin)\n        val defenderLocation = location.add(0.0, 0.0, 2.0)\n        fakeDefender.spawn(defenderLocation)\n\n        attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))\n        defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))\n\n        // Turn defender to face attacker\n        defenderLocation.yaw = 180f\n        defenderLocation.pitch = 0f\n        defender.teleport(defenderLocation)\n\n        // modeset of attacker takes precedence\n        var playerData = getPlayerData(attacker.uniqueId)\n        playerData.setModesetForWorld(attacker.world.uid, \"old\")\n        setPlayerData(attacker.uniqueId, playerData)\n\n        playerData = getPlayerData(defender.uniqueId)\n        playerData.setModesetForWorld(defender.world.uid, \"new\")\n        setPlayerData(defender.uniqueId, playerData)\n\n        beforeAll()\n        tally = Tally()\n\n        // Queue all tests\n        //runAttacks(new ItemStack[]{}, () -> {}); // with no armour\n        testArmour()\n\n        //testEnchantedMelee(new ItemStack[]{}, () -> {});\n\n        // Run all tests in the queue\n        runQueuedTests()\n    }\n\n    private fun runAttacks(armour: Array<ItemStack>, preparations: Runnable) {\n        //testMelee(armour, preparations);\n        testEnchantedMelee(armour, preparations)\n        testOverdamage(armour, preparations)\n    }\n\n    private fun testArmour() {\n        val materials = arrayOf(\"LEATHER\", \"CHAINMAIL\", \"GOLDEN\", \"IRON\", \"DIAMOND\", \"NETHERITE\")\n        val slots = arrayOf(\"BOOTS\", \"LEGGINGS\", \"CHESTPLATE\", \"HELMET\")\n        val random = Random(System.currentTimeMillis())\n\n        val armourContents = Array(4) { i ->\n            val slot = slots[i]\n            // Pick a random material for each slot\n            val material = materials[random.nextInt(materials.size)]\n\n            val itemStack = ItemStack(Material.valueOf(\"${material}_$slot\"))\n\n            // Apply enchantment to the armour piece\n            itemStack.addUnsafeEnchantment(XEnchantment.PROTECTION.get()!!, 50)\n\n            itemStack\n        }\n\n        runAttacks(armourContents) {\n            defender.inventory.setArmorContents(armourContents)\n            // Test status effects on defence: resistance, fire resistance, absorption\n            defender.addPotionEffect(PotionEffect(XPotion.RESISTANCE.potionEffectType!!, 10, 1))\n            fakeDefender.doBlocking()\n        }\n    }\n\n    private fun testEnchantedMelee(armour: Array<ItemStack>, preparations: Runnable) {\n        for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {\n            val weapon = ItemStack(weaponType)\n\n            // only axe and sword can have sharpness\n            try {\n                weapon.addEnchantment(XEnchantment.SHARPNESS.get()!!, 3)\n            } catch (ignored: IllegalArgumentException) {\n            }\n\n            val message = weaponType.name + \" Sharpness 3\"\n            queueAttack(OCMTest(weapon, armour, 2, message) {\n                preparations.run()\n                defender.maximumNoDamageTicks = 0\n                attacker.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 10, 0, false))\n                attacker.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 10, -1, false))\n                plugin.logger.info(\"TESTING WEAPON $weaponType\")\n                attacker.fallDistance = 2f // Crit\n            })\n        }\n    }\n\n    private fun testMelee(armour: Array<ItemStack>, preparations: Runnable) {\n        for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {\n            val weapon = ItemStack(weaponType)\n            queueAttack(OCMTest(weapon, armour, 1, weaponType.name) {\n                preparations.run()\n                defender.maximumNoDamageTicks = 0\n            })\n        }\n    }\n\n    private fun testOverdamage(armour: Array<ItemStack>, preparations: Runnable) {\n        // 1, 5, 6, 7, 3, 8 according to OCM\n        // 1, 4, 5, 6, 2, 7 according to 1.9+\n        val weapons = arrayOf(\n            Material.WOODEN_HOE,\n            Material.WOODEN_SWORD,\n            Material.STONE_SWORD,\n            Material.IRON_SWORD,\n            Material.WOODEN_PICKAXE,\n            Material.DIAMOND_SWORD\n        )\n\n        for (weaponType in weapons) {\n            val weapon = ItemStack(weaponType)\n            queueAttack(OCMTest(weapon, armour, 3, weaponType.name, Runnable {\n                preparations.run()\n                defender.maximumNoDamageTicks = 30\n            }))\n        }\n    }\n\n    private fun queueAttack(test: OCMTest) {\n        testQueue.add(test)\n    }\n\n    private fun calculateAttackDamage(weapon: ItemStack): Double {\n        val weaponType = weapon.type\n        // Attack components order: (Base + Potion effects, scaled by attack delay) + Critical Hit + (Enchantments, scaled by attack delay)\n        // Hurt components order: Overdamage - Armour Effects\n        var expectedDamage = getDamage(weaponType)\n\n        // Weakness effect, 1.8: -0.5\n        // We ignore the level as there is only one level of weakness potion\n        val weaknessAddend = if (attacker.hasPotionEffect(XPotion.WEAKNESS.get()!!)) -0.5 else 0.0\n\n        // Strength effect\n        // 1.8: +130% for each strength level\n        val strength = attacker.getPotionEffectCompat(XPotion.STRENGTH.get()!!)\n        if (strength != null) expectedDamage += (strength.amplifier + 1) * 1.3 * expectedDamage\n\n        expectedDamage += weaknessAddend\n\n        // Take into account damage reduction because of cooldown\n        val attackCooldown = defender.attackCooldown\n        expectedDamage *= (0.2f + attackCooldown * attackCooldown * 0.8f).toDouble()\n\n        // Critical hit\n        if (isCriticalHit1_8(attacker)) {\n            expectedDamage *= 1.5\n        }\n\n        // Weapon Enchantments\n        var sharpnessDamage = getOldSharpnessDamage(weapon.getEnchantmentLevel(XEnchantment.SHARPNESS.get()!!))\n        sharpnessDamage *= attackCooldown.toDouble() // Scale by attack cooldown strength\n        expectedDamage += sharpnessDamage\n\n        return expectedDamage\n    }\n\n    private fun wasFakeOverdamage(weapon: ItemStack): Boolean {\n        val weaponDamage = calculateAttackDamage(weapon)\n        val lastDamage = defender.lastDamage\n        return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&\n                weaponDamage <= lastDamage\n    }\n\n    private fun wasOverdamaged(rawWeaponDamage: Double): Boolean {\n        val lastDamage = defender.lastDamage\n        return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&\n                rawWeaponDamage > lastDamage\n    }\n\n    private fun calculateExpectedDamage(weapon: ItemStack, armourContents: Array<ItemStack>): Float {\n        var expectedDamage = calculateAttackDamage(weapon)\n\n        if (wasOverdamaged(expectedDamage)) {\n            val lastDamage = defender.lastDamage\n            plugin.logger.info(\"Overdamaged: \" + expectedDamage + \" - \" + lastDamage + \" = \" + (expectedDamage - lastDamage))\n            expectedDamage -= lastDamage\n        }\n\n        if (defender.isBlocking) {\n            plugin.logger.info(\"DEFENDER IS BLOCKING $expectedDamage\")\n            expectedDamage -= max(0.0, (expectedDamage - 1)) * 0.5\n            plugin.logger.info(\"AFTER BLOCK $expectedDamage\")\n        }\n\n        expectedDamage = getDamageAfterArmour1_8(\n            defender,\n            expectedDamage,\n            armourContents,\n            EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n            false\n        )\n\n        return expectedDamage.toFloat()\n    }\n\n    private fun runQueuedTests() {\n        plugin.logger.info(\"Running \" + testQueue.size + \" tests\")\n\n        val listener: Listener = object : Listener {\n            @EventHandler(priority = EventPriority.MONITOR)\n            fun onEvent(e: EntityDamageByEntityEvent) {\n                val damager = e.damager\n                if (damager.uniqueId !== attacker.uniqueId ||\n                    e.entity.uniqueId !== defender.uniqueId\n                ) return\n\n                val weapon = (damager as Player).inventory.itemInMainHand\n                val weaponType = weapon.type\n                var test = testQueue.remove()\n                var expectedWeapon = test.weapon\n                var expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)\n\n                while (weaponType != expectedWeapon.type) {\n                    expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)\n                    plugin.logger.info(\"SKIPPED \" + expectedWeapon.type + \" Expected Damage: \" + expectedDamage)\n                    if (expectedDamage == 0f) tally!!.passed()\n                    else tally!!.failed()\n                    test = testQueue.remove()\n                    expectedWeapon = test.weapon\n                }\n\n                if (wasFakeOverdamage(weapon) && e.isCancelled) {\n                    plugin.logger.info(\"PASSED Fake overdamage \" + expectedDamage + \" < \" + (e.entity as LivingEntity).lastDamage)\n                    tally!!.passed()\n                } else {\n                    val weaponMessage = \"E: \" + expectedWeapon.type.name + \" A: \" + weaponType.name\n                    assertEquals(\n                        expectedDamage, e.finalDamage.toFloat(),\n                        tally!!, weaponMessage, sender!!\n                    )\n                }\n            }\n        }\n\n        Bukkit.getServer().pluginManager.registerEvents(listener, plugin)\n\n        val testCount = testQueue.size.toLong()\n\n        var attackDelay: Long = 0\n\n        for (test in testQueue) {\n            attackDelay += test.attackDelay\n\n            Bukkit.getScheduler().runTaskLater(plugin, Runnable {\n                beforeEach()\n                test.preparations.run()\n                preparePlayer(test.weapon)\n                attackCompat(attacker, defender)\n                afterEach()\n            }, attackDelay)\n        }\n\n        Bukkit.getScheduler().runTaskLater(plugin, Runnable {\n            afterAll(testCount)\n            EntityDamageByEntityEvent.getHandlerList().unregister(listener)\n        }, attackDelay + 1)\n    }\n\n    private fun beforeAll() {\n        plugin.logger.info(\"Running before all\")\n        for (player in listOfNotNull(attacker, defender)) {\n            player.gameMode = GameMode.SURVIVAL\n            player.maximumNoDamageTicks = 20\n            player.noDamageTicks = 0 // remove spawn invulnerability\n            player.isInvulnerable = false\n        }\n    }\n\n    private fun afterAll(testCount: Long) {\n        fakeAttacker.removePlayer()\n        fakeDefender.removePlayer()\n\n        val missed = testCount - tally!!.total\n        val message = String.format(\n            \"Passed: %d Failed: %d Total: %d Missed: %d\",\n            tally!!.passed,\n            tally!!.failed,\n            tally!!.total,\n            missed\n        )\n        plugin.logger.info(message)\n    }\n\n    private fun beforeEach() {\n        for (player in listOfNotNull(attacker, defender)) {\n            player.inventory.clear()\n            player.exhaustion = 0f\n            player.health = 20.0\n        }\n    }\n\n    private fun preparePlayer(weapon: ItemStack) {\n        if (weapon.hasItemMeta()) {\n            val meta = weapon.itemMeta\n            val speedModifier = createAttributeModifier(\n                name = \"speed\",\n                amount = 1000.0,\n                operation = AttributeModifier.Operation.ADD_NUMBER,\n                slot = EquipmentSlot.HAND\n            )\n            val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()\n            if (attackSpeedAttribute != null) {\n                addAttributeModifierCompat(meta!!, attackSpeedAttribute, speedModifier)\n            }\n            weapon.setItemMeta(meta)\n        }\n        attacker.inventory.setItemInMainHand(weapon)\n        attacker.updateInventory()\n\n        val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n        val armourAttribute = XAttribute.ARMOR.get()\n        val ai = attackDamageAttribute?.let { attacker.getAttribute(it) }\n        val defenderArmour = armourAttribute?.let { defender.getAttribute(it) }\n\n        if (attackDamageAttribute != null && ai != null) {\n            getDefaultAttributeModifiersCompat(weapon, EquipmentSlot.HAND, attackDamageAttribute).forEach(\n                Consumer { am: AttributeModifier? ->\n                    ai.removeModifier(am!!)\n                    ai.addModifier(am)\n                })\n        }\n\n        val armourContents = defender.inventory.armorContents\n        plugin.logger.info(\n            \"Armour: \" + Arrays.stream(armourContents).filter { obj: ItemStack? -> Objects.nonNull(obj) }\n                .map { `is`: ItemStack -> `is`.type.name }\n                .reduce { a: String, b: String -> \"$a, $b\" }\n                .orElse(\"none\")\n        )\n        for (i in armourContents.indices) {\n            val itemStack = armourContents[i] ?: continue\n            val type = itemStack.type\n            val slot =\n                arrayOf(\n                    EquipmentSlot.FEET,\n                    EquipmentSlot.LEGS,\n                    EquipmentSlot.CHEST,\n                    EquipmentSlot.HEAD\n                )[i]\n            if (armourAttribute != null && defenderArmour != null) {\n                for (attributeModifier in getDefaultAttributeModifiersCompat(itemStack, slot, armourAttribute)) {\n                    defenderArmour.removeModifier(attributeModifier)\n                    defenderArmour.addModifier(attributeModifier)\n                }\n            }\n        }\n    }\n\n    private fun afterEach() {\n        for (player in listOfNotNull(attacker, defender)) {\n            player.exhaustion = 0f\n            player.health = 20.0\n        }\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InGameTesterIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.StringSpec\nimport io.kotest.matchers.doubles.shouldBeExactly\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.EntityType\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass InGameTesterIntegrationTest :\n    StringSpec({\n        val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        extension(MainThreadDispatcherExtension(plugin))\n        lateinit var attacker: Player\n        lateinit var defender: Player\n        lateinit var fakeAttacker: FakePlayer\n        lateinit var fakeDefender: FakePlayer\n\n        fun <T> runSync(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit.getScheduler().callSyncMethod(plugin, Callable { action() }).get()\n            }\n\n        fun preparePlayers() {\n            println(\"Preparing players\")\n            val world = Bukkit.getServer().getWorld(\"world\")\n            // TODO might need to specify server superflat?\n            val location = Location(world, 0.0, 100.0, 0.0)\n\n            fakeAttacker = FakePlayer(plugin)\n            fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))\n            fakeDefender = FakePlayer(plugin)\n            val defenderLocation = location.add(0.0, 0.0, 2.0)\n            fakeDefender.spawn(defenderLocation)\n\n            attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))\n            defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))\n\n            // Turn defender to face attacker\n            defenderLocation.yaw = 180f\n            defenderLocation.pitch = 0f\n            defender.teleport(defenderLocation)\n\n            // modeset of attacker takes precedence\n            var playerData = getPlayerData(attacker.uniqueId)\n            playerData.setModesetForWorld(attacker.world.uid, \"old\")\n            setPlayerData(attacker.uniqueId, playerData)\n\n            playerData = getPlayerData(defender.uniqueId)\n            playerData.setModesetForWorld(defender.world.uid, \"new\")\n            setPlayerData(defender.uniqueId, playerData)\n        }\n\n        beforeSpec {\n            plugin.logger.info(\"Running before all\")\n            runSync { preparePlayers() }\n        }\n\n        beforeTest {\n            runSync {\n                for (player in listOfNotNull(attacker, defender)) {\n                    player.gameMode = GameMode.SURVIVAL\n                    player.maximumNoDamageTicks = 20\n                    player.noDamageTicks = 0 // remove spawn invulnerability\n                    player.isInvulnerable = false\n                }\n            }\n        }\n\n        afterSpec {\n            plugin.logger.info(\"Running after all\")\n            runSync {\n                fakeAttacker.removePlayer()\n                fakeDefender.removePlayer()\n            }\n        }\n\n        \"test melee attacks\" {\n            println(\"Testing melee attack\")\n            val netheriteSword = runCatching { Material.valueOf(\"NETHERITE_SWORD\") }.getOrNull()\n            val weapon = ItemStack(netheriteSword ?: Material.STONE_SWORD)\n            val victim =\n                runSync {\n                    attacker.world.spawnEntity(attacker.location.clone().add(1.5, 0.0, 0.0), EntityType.ZOMBIE) as LivingEntity\n                }\n            try {\n                runSync {\n                    attacker.inventory.setItemInMainHand(weapon)\n                    attacker.updateInventory()\n                    victim.maximumNoDamageTicks = 0\n                    victim.noDamageTicks = 0\n                }\n\n                var damageEvents = 0\n                var sawExpectedWeaponAtDamage = false\n                var sawMeleeCause = false\n                var sawPositiveUncancelledDamage = false\n                val listener =\n                    object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR)\n                        fun onEntityDamageByEntity(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId != attacker.uniqueId || event.entity.uniqueId != victim.uniqueId) {\n                                return\n                            }\n                            damageEvents += 1\n                            sawExpectedWeaponAtDamage = (attacker.inventory.itemInMainHand.type == weapon.type)\n                            sawMeleeCause =\n                                event.cause == EntityDamageEvent.DamageCause.ENTITY_ATTACK ||\n                                event.cause == EntityDamageEvent.DamageCause.ENTITY_SWEEP_ATTACK\n                            if (!event.isCancelled && event.finalDamage > 0.0) {\n                                sawPositiveUncancelledDamage = true\n                            }\n                        }\n                    }\n                runSync { Bukkit.getPluginManager().registerEvents(listener, plugin) }\n\n                val victimStartHealth = runSync { victim.health }\n                var minimumVictimHealth = victimStartHealth\n                try {\n                    repeat(12) {\n                        val damaged =\n                            runSync {\n                                attackCompat(attacker, victim)\n                                minimumVictimHealth = minOf(minimumVictimHealth, victim.health)\n                                minimumVictimHealth < victimStartHealth && sawPositiveUncancelledDamage\n                            }\n                        if (damaged) {\n                            return@repeat\n                        }\n                        delay(2 * 50L)\n                    }\n                } finally {\n                    runSync { EntityDamageByEntityEvent.getHandlerList().unregister(listener) }\n                }\n\n                repeat(4) {\n                    runSync {\n                        minimumVictimHealth = minOf(minimumVictimHealth, victim.health)\n                    }\n                    if (minimumVictimHealth < victimStartHealth) {\n                        return@repeat\n                    }\n                    delay(50L)\n                }\n\n                @Suppress(\"DEPRECATION\") // Deprecated API kept for older server compatibility in tests.\n                runSync { attacker.health } shouldBeExactly runSync { attacker.maxHealth }\n                sawPositiveUncancelledDamage shouldBe true\n                (minimumVictimHealth < victimStartHealth) shouldBe true\n                (damageEvents > 0) shouldBe true\n                sawExpectedWeaponAtDamage shouldBe true\n                sawMeleeCause shouldBe true\n            } finally {\n                runSync { victim.remove() }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InvulnerabilityDamageIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XMaterial\nimport com.cryptomorin.xseries.XPotion\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual\nimport io.kotest.matchers.ints.shouldBeExactly\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.player.PlayerItemConsumeEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.inventory.meta.PotionMeta\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.potion.PotionData\nimport org.bukkit.potion.PotionEffect\nimport org.bukkit.potion.PotionEffectType\nimport org.bukkit.potion.PotionType\nimport kotlinx.coroutines.delay\nimport java.util.UUID\nimport java.util.concurrent.Callable\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kotlin.math.abs\n\n@OptIn(ExperimentalKotest::class)\nclass InvulnerabilityDamageIntegrationTest : FunSpec({\n    val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    extensions(MainThreadDispatcherExtension(plugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(plugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    suspend fun delayTicks(ticks: Long) {\n        delay(ticks * 50L)\n    }\n\n    fun prepareWeapon(item: ItemStack) {\n        val meta = item.itemMeta ?: return\n        val speedModifier = createAttributeModifier(\n            name = \"speed\",\n            amount = 1000.0,\n            operation = AttributeModifier.Operation.ADD_NUMBER,\n            slot = EquipmentSlot.HAND\n        )\n        val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n        addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n        item.itemMeta = meta\n    }\n\n    fun applyAttackDamageModifiers(player: Player, item: ItemStack) {\n        val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n        val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n        val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n        val expectedAmounts = modifiers\n            .filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }\n            .map { it.amount }\n        val knownWeaponAmounts = NewWeaponDamage.values()\n            .map { it.damage.toDouble() - 1.0 }\n            .filter { it > 0.0 }\n            .toSet()\n\n        fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001\n\n        val existingModifiers = attackAttribute.modifiers.toList()\n        existingModifiers\n            .filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }\n            .filter { modifier ->\n                knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&\n                    expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }\n            }\n            .forEach { attackAttribute.removeModifier(it) }\n\n        modifiers.forEach { modifier ->\n            val alreadyApplied = attackAttribute.modifiers.any {\n                it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)\n            }\n            if (!alreadyApplied) {\n                attackAttribute.addModifier(modifier)\n            }\n        }\n    }\n\n    fun equip(player: Player, item: ItemStack) {\n        prepareWeapon(item)\n        player.inventory.setItemInMainHand(item)\n        applyAttackDamageModifiers(player, item)\n        player.updateInventory()\n    }\n\n    fun spawnAttacker(location: Location): Pair<FakePlayer, Player> {\n        val fake = FakePlayer(plugin)\n        fake.spawn(location)\n        val player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n        player.gameMode = GameMode.SURVIVAL\n        player.isInvulnerable = false\n        player.inventory.clear()\n        player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n        val playerData = getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, \"old\")\n        setPlayerData(player.uniqueId, playerData)\n        return fake to player\n    }\n\n    fun spawnVictim(location: Location): LivingEntity {\n        val world = location.world ?: error(\"World missing for victim spawn\")\n        return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {\n            maximumNoDamageTicks = 20\n            noDamageTicks = 0\n            isInvulnerable = false\n            health = maxHealth\n        }\n    }\n\n    fun createWeaknessPotion(): ItemStack {\n        val item = ItemStack(Material.POTION)\n        val meta = item.itemMeta as PotionMeta\n        try {\n            meta.basePotionType = PotionType.WEAKNESS\n        } catch (e: NoSuchMethodError) {\n            @Suppress(\"DEPRECATION\") // Required for legacy server compatibility.\n            meta.basePotionData = PotionData(PotionType.WEAKNESS, false, false)\n        }\n        item.itemMeta = meta\n        return item\n    }\n\n    fun consumeWeaknessPotion(player: Player): PotionEffect {\n        val item = createWeaknessPotion()\n        val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 3 &&\n                Player::class.java.isAssignableFrom(params[0]) &&\n                ItemStack::class.java.isAssignableFrom(params[1]) &&\n                EquipmentSlot::class.java.isAssignableFrom(params[2])\n        }\n        val event = if (ctor != null) {\n            ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent\n        } else {\n            PlayerItemConsumeEvent(player, item)\n        }\n        Bukkit.getPluginManager().callEvent(event)\n        val meta = event.item.itemMeta as PotionMeta\n        return meta.customEffects.firstOrNull { it.type == PotionEffectType.WEAKNESS }\n            ?: error(\"Weakness effect missing from potion meta\")\n    }\n\n    test(\"second hit in the same tick should still fire inside invulnerability\").config(\n        enabled = false\n    ) {\n        // Disabled: current vanilla pipeline drops same-tick follow-up hits during invulnerability.\n        // We will revisit once the intended behaviour is defined across versions.\n        val events = mutableListOf<EntityDamageByEntityEvent>()\n        lateinit var attacker1: Player\n        lateinit var attacker2: Player\n        var victim: LivingEntity? = null\n        var fake1: FakePlayer? = null\n        var fake2: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    (event.damager.uniqueId == attacker1.uniqueId || event.damager.uniqueId == attacker2.uniqueId)\n                ) {\n                    events.add(event)\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attacker1Location = Location(world, 0.0, 100.0, 0.0)\n                val attacker2Location = Location(world, 0.0, 100.0, 2.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnAttacker(attacker1Location)\n                val (fakeB, playerB) = spawnAttacker(attacker2Location)\n                fake1 = fakeA\n                fake2 = fakeB\n                attacker1 = playerA\n                attacker2 = playerB\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                equip(attacker1, ItemStack(Material.DIAMOND_SWORD))\n                equip(attacker2, ItemStack(Material.STONE_SWORD))\n\n                attackCompat(attacker1, spawnedVictim)\n                attackCompat(attacker2, spawnedVictim)\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val attacker1Damage = attackDamageAttribute?.let { attacker1.getAttribute(it)?.value }\n                val attacker2Damage = attackDamageAttribute?.let { attacker2.getAttribute(it)?.value }\n                plugin.logger.info(\n                    \"Invuln same-tick debug: events=${events.size} \" +\n                        \"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} \" +\n                        \"attacker1Damage=$attacker1Damage attacker2Damage=$attacker2Damage\"\n                )\n            }\n            delayTicks(3)\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                plugin.logger.info(\n                    \"Invuln same-tick debug (post): events=${events.size} \" +\n                        \"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} \" +\n                        \"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}\"\n                )\n            }\n            events.size.shouldBeExactly(2)\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fake1?.removePlayer()\n                fake2?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"slightly higher base damage inside invulnerability should fire\")\n        .config(enabled = false) {\n        // Ignored for now: behaviour differs across versions and is not addressed yet.\n        val events = mutableListOf<EntityDamageByEntityEvent>()\n        lateinit var attacker: Player\n        var victim: LivingEntity? = null\n        var fake: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    event.damager.uniqueId == attacker.uniqueId\n                ) {\n                    events.add(event)\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnAttacker(attackerLocation)\n                fake = fakeA\n                attacker = playerA\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                val woodenSword = XMaterial.WOODEN_SWORD.parseItem()\n                    ?: error(\"WOODEN_SWORD material not available\")\n                equip(attacker, woodenSword)\n                attackCompat(attacker, spawnedVictim)\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }\n                plugin.logger.info(\n                    \"Invuln overdamage debug (first hit): events=${events.size} \" +\n                        \"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} \" +\n                        \"attackerDamage=$attackerDamage\"\n                )\n            }\n\n            delayTicks(2)\n\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                val firstDamage = events.firstOrNull()?.damage\n                    ?: error(\"Expected a damage event from the first hit\")\n                currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks\n                currentVictim.lastDamage = firstDamage\n\n                val stoneSword = XMaterial.STONE_SWORD.parseItem()\n                    ?: error(\"STONE_SWORD material not available\")\n                equip(attacker, stoneSword)\n                attackCompat(attacker, currentVictim)\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }\n                plugin.logger.info(\n                    \"Invuln overdamage debug (second hit): events=${events.size} \" +\n                        \"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} \" +\n                        \"attackerDamage=$attackerDamage firstDamage=$firstDamage\"\n                )\n            }\n\n            delayTicks(2)\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                plugin.logger.info(\n                    \"Invuln overdamage debug (post): events=${events.size} \" +\n                        \"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} \" +\n                        \"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}\"\n                )\n            }\n            events.size.shouldBeExactly(2)\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fake?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"clearly higher base damage inside invulnerability should fire\") {\n        val events = mutableListOf<EntityDamageByEntityEvent>()\n        lateinit var attacker: Player\n        var victim: LivingEntity? = null\n        var fake: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    event.damager.uniqueId == attacker.uniqueId\n                ) {\n                    events.add(event)\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnAttacker(attackerLocation)\n                fake = fakeA\n                attacker = playerA\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                val woodenSword = XMaterial.WOODEN_SWORD.parseItem()\n                    ?: error(\"WOODEN_SWORD material not available\")\n                equip(attacker, woodenSword)\n                attackCompat(attacker, spawnedVictim)\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }\n                plugin.logger.info(\n                    \"Invuln overdamage (iron) debug (first hit): events=${events.size} \" +\n                        \"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} \" +\n                        \"attackerDamage=$attackerDamage\"\n                )\n            }\n\n            delayTicks(2)\n\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                val firstDamage = events.firstOrNull()?.damage\n                    ?: error(\"Expected a damage event from the first hit\")\n                currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks\n                currentVictim.lastDamage = firstDamage\n\n                val ironSword = XMaterial.IRON_SWORD.parseItem()\n                    ?: error(\"IRON_SWORD material not available\")\n                equip(attacker, ironSword)\n                attackCompat(attacker, currentVictim)\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }\n                plugin.logger.info(\n                    \"Invuln overdamage (iron) debug (second hit): events=${events.size} \" +\n                        \"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} \" +\n                        \"attackerDamage=$attackerDamage firstDamage=$firstDamage\"\n                )\n            }\n\n            delayTicks(2)\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                plugin.logger.info(\n                    \"Invuln overdamage (iron) debug (post): events=${events.size} \" +\n                        \"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} \" +\n                        \"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}\"\n                )\n            }\n            events.size.shouldBeExactly(2)\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fake?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"zero vanilla damage should still fire after lastDamage reset\").config(\n        enabled = false\n    ) {\n        // Disabled: behaviour diverges across versions; we will revisit once vanilla expectations are finalised.\n        val events = mutableListOf<EntityDamageByEntityEvent>()\n        lateinit var attacker: Player\n        var victim: LivingEntity? = null\n        var fake: FakePlayer? = null\n\n        val listener = object : Listener {\n            @EventHandler\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId == currentVictim.uniqueId &&\n                    event.damager.uniqueId == attacker.uniqueId\n                ) {\n                    events.add(event)\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnAttacker(attackerLocation)\n                fake = fakeA\n                attacker = playerA\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n\n                Bukkit.getPluginManager().registerEvents(listener, plugin)\n\n                equip(attacker, ItemStack(Material.DIAMOND_SWORD))\n                attackCompat(attacker, spawnedVictim)\n            }\n\n            delayTicks(2) // allow MONITOR handler to set lastDamage to 0\n\n            runSync {\n                val currentVictim = checkNotNull(victim)\n                currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks\n                currentVictim.lastDamage.shouldBe(0.0)\n\n                val effect = consumeWeaknessPotion(attacker)\n                attacker.addPotionEffect(effect, true)\n                val woodenSword = XMaterial.WOODEN_SWORD.parseItem() ?: ItemStack(Material.STONE_SWORD)\n                equip(attacker, woodenSword)\n                attackCompat(attacker, currentVictim)\n            }\n\n            delayTicks(3)\n            events.size.shouldBeExactly(2)\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                fake?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"weakness should not store negative last damage values\") {\n        var attacker: Player? = null\n        var victim: LivingEntity? = null\n        var fake: FakePlayer? = null\n        var edbelListener: Listener? = null\n        var lastDamagesField: java.lang.reflect.Field? = null\n        var damageEvents = 0\n        val damageListener = object : Listener {\n            @EventHandler(ignoreCancelled = false)\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                val currentVictim = victim ?: return\n                val currentAttacker = attacker ?: return\n                if (event.entity.uniqueId != currentVictim.uniqueId) return\n                if (event.damager.uniqueId != currentAttacker.uniqueId) return\n                damageEvents += 1\n            }\n        }\n\n        val oldWeaknessModifier = ocm.config.getDouble(\"old-potion-effects.weakness.modifier\")\n        val oldWeaknessMultiplier = ocm.config.getBoolean(\"old-potion-effects.weakness.multiplier\")\n\n        try {\n            runSync {\n                ocm.config.set(\"old-potion-effects.weakness.modifier\", -10.0)\n                ocm.config.set(\"old-potion-effects.weakness.multiplier\", false)\n                ocm.saveConfig()\n                Config.reload()\n\n                edbelListener = HandlerList.getRegisteredListeners(ocm)\n                    .map { it.listener }\n                    .firstOrNull { it.javaClass.name.endsWith(\"EntityDamageByEntityListener\") }\n                    ?: error(\"EntityDamageByEntityListener not registered\")\n                lastDamagesField = edbelListener?.javaClass?.getDeclaredField(\"lastDamages\")?.apply {\n                    isAccessible = true\n                } ?: error(\"Failed to resolve lastDamages field\")\n\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                val (fakeA, playerA) = spawnAttacker(attackerLocation)\n                fake = fakeA\n                attacker = playerA\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n\n                Bukkit.getPluginManager().registerEvents(damageListener, plugin)\n\n                val effect = consumeWeaknessPotion(playerA)\n                playerA.addPotionEffect(effect, true)\n                equip(playerA, ItemStack(Material.IRON_SWORD))\n                attackCompat(playerA, spawnedVictim)\n                val currentVictim = checkNotNull(victim)\n                @Suppress(\"UNCHECKED_CAST\")\n                val lastDamages = lastDamagesField?.get(edbelListener) as Map<UUID, Double>\n                val stored = lastDamages[currentVictim.uniqueId]\n                    ?: error(\"No stored last damage for victim (events=$damageEvents)\")\n                damageEvents.shouldBeExactly(1)\n                stored.shouldBeGreaterThanOrEqual(0.0)\n            }\n        } finally {\n            HandlerList.unregisterAll(damageListener)\n            runSync {\n                ocm.config.set(\"old-potion-effects.weakness.modifier\", oldWeaknessModifier)\n                ocm.config.set(\"old-potion-effects.weakness.multiplier\", oldWeaknessMultiplier)\n                ocm.saveConfig()\n                Config.reload()\n                fake?.removePlayer()\n                victim?.remove()\n            }\n        }\n    }\n\n    test(\"environmental damage above baseline should apply during invulnerability\") {\n        var victim: LivingEntity? = null\n        var edbelListener: Listener? = null\n        var lastDamagesField: java.lang.reflect.Field? = null\n        var capturedEvent: EntityDamageEvent? = null\n\n        val eventListener = object : Listener {\n            @EventHandler(ignoreCancelled = false)\n            fun onDamage(event: EntityDamageEvent) {\n                val currentVictim = victim ?: return\n                if (event.entity.uniqueId != currentVictim.uniqueId) return\n                if (event is EntityDamageByEntityEvent) return\n                capturedEvent = event\n            }\n        }\n\n        try {\n            runSync {\n                edbelListener = HandlerList.getRegisteredListeners(ocm)\n                    .map { it.listener }\n                    .firstOrNull { it.javaClass.name.endsWith(\"EntityDamageByEntityListener\") }\n                    ?: error(\"EntityDamageByEntityListener not registered\")\n                lastDamagesField = edbelListener?.javaClass?.getDeclaredField(\"lastDamages\")?.apply {\n                    isAccessible = true\n                } ?: error(\"Failed to resolve lastDamages field\")\n\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n                val spawnedVictim = spawnVictim(victimLocation)\n                victim = spawnedVictim\n                spawnedVictim.maximumNoDamageTicks = 20\n                spawnedVictim.noDamageTicks = 20\n                spawnedVictim.lastDamage = 0.0\n\n                @Suppress(\"UNCHECKED_CAST\")\n                val lastDamages = lastDamagesField?.get(edbelListener) as MutableMap<UUID, Double>\n                lastDamages[spawnedVictim.uniqueId] = 5.0\n\n                Bukkit.getPluginManager().registerEvents(eventListener, plugin)\n\n                val event = EntityDamageEvent(\n                    spawnedVictim,\n                    EntityDamageEvent.DamageCause.FALL,\n                    12.0\n                )\n                Bukkit.getPluginManager().callEvent(event)\n            }\n\n            runSync {\n                val event = checkNotNull(capturedEvent)\n                event.isCancelled.shouldBe(false)\n                event.damage.shouldBe(7.0)\n            }\n        } finally {\n            HandlerList.unregisterAll(eventListener)\n            runSync {\n                @Suppress(\"UNCHECKED_CAST\")\n                val lastDamages = lastDamagesField?.get(edbelListener) as? MutableMap<UUID, Double>\n                lastDamages?.remove(victim?.uniqueId)\n                victim?.remove()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/KotestRunner.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.github.ajalt.mordant.TermColors\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.common.KotestInternal\nimport io.kotest.core.config.AbstractProjectConfig\nimport io.kotest.core.extensions.TestCaseExtension\nimport io.kotest.core.spec.IsolationMode\nimport io.kotest.core.test.TestCase\nimport io.kotest.core.test.TestCaseOrder\nimport io.kotest.core.test.TestResult\nimport io.kotest.engine.TestEngineLauncher\nimport io.kotest.engine.listener.AbstractTestEngineListener\nimport io.kotest.engine.listener.CompositeTestEngineListener\nimport io.kotest.engine.listener.EnhancedConsoleTestEngineListener\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.withContext\nimport org.bukkit.Bukkit\nimport org.bukkit.plugin.java.JavaPlugin\nimport kotlin.coroutines.CoroutineContext\nimport kotlin.coroutines.coroutineContext\n\n@OptIn(ExperimentalKotest::class)\nobject KotestProjectConfig : AbstractProjectConfig() {\n    override val isolationMode = IsolationMode.SingleInstance\n    override val concurrentSpecs = 1\n    override val concurrentTests = 1\n    override val testCaseOrder = TestCaseOrder.Sequential\n}\n\nclass BukkitMainThreadDispatcher(\n    private val plugin: JavaPlugin,\n) : CoroutineDispatcher() {\n    override fun dispatch(\n        context: CoroutineContext,\n        block: Runnable,\n    ) {\n        Bukkit.getScheduler().runTask(plugin, block)\n    }\n}\n\nclass MainThreadDispatcherExtension(\n    private val plugin: JavaPlugin,\n) : TestCaseExtension {\n    override suspend fun intercept(\n        testCase: TestCase,\n        execute: suspend (TestCase) -> TestResult,\n    ): TestResult {\n        val dispatcher = BukkitMainThreadDispatcher(plugin)\n        val newContext = coroutineContext + dispatcher\n        return withContext(newContext) {\n            execute(testCase)\n        }\n    }\n}\n\nobject KotestRunner {\n    @OptIn(KotestInternal::class)\n    @JvmStatic\n    fun run(plugin: JavaPlugin) {\n        // Schedule test asynchronously to avoid deadlock.\n        Bukkit.getScheduler().runTaskAsynchronously(\n            plugin,\n            Runnable {\n                try {\n                    var hasFailures = false\n                    val failureLines = ArrayList<String>(16)\n\n                    fun throwableFromResult(result: TestResult): Throwable? {\n                        // Avoid depending on Kotest internals: fetch any Throwable via reflection for cross-version tolerance.\n                        val candidateGetters =\n                            listOf(\n                                \"getErrorOrNull\",\n                                \"getCauseOrNull\",\n                                \"getThrowableOrNull\",\n                                \"getFailureOrNull\",\n                            )\n                        for (getter in candidateGetters) {\n                            val m = result::class.java.methods.firstOrNull { it.name == getter && it.parameterCount == 0 } ?: continue\n                            val t = runCatching { m.invoke(result) }.getOrNull() as? Throwable\n                            if (t != null) return t\n                        }\n                        return null\n                    }\n\n                    fun formatFailure(\n                        testCase: TestCase,\n                        result: TestResult,\n                    ): String {\n                        val specName = testCase.spec::class.qualifiedName ?: testCase.spec::class.java.name\n                        val testName = testCase.displayName\n                        val t = throwableFromResult(result)\n                        if (t == null) return \"$specName, $testName\"\n\n                        val message =\n                            t.message\n                                ?.lineSequence()\n                                ?.firstOrNull()\n                                ?.trim()\n                                .orEmpty()\n                        val head = if (message.isNotEmpty()) \"${t::class.java.simpleName}: $message\" else t::class.java.simpleName\n                        val frame = t.stackTrace.firstOrNull()\n                        val at = if (frame != null) \" (${frame.fileName}:${frame.lineNumber})\" else \"\"\n                        return \"$specName, $testName -- $head$at\"\n                    }\n\n                    val listener =\n                        object : AbstractTestEngineListener() {\n                            override suspend fun testFinished(\n                                testCase: TestCase,\n                                result: TestResult,\n                            ) {\n                                if (result.isFailure || result.isError) {\n                                    hasFailures = true\n                                    if (failureLines.size < 25) {\n                                        failureLines.add(formatFailure(testCase, result))\n                                    }\n                                }\n                            }\n\n                            override suspend fun engineFinished(t: List<Throwable>) {\n                                val success = t.isEmpty() && !hasFailures\n                                TestResultWriter.writeFailureSummary(plugin, failureLines)\n                                TestResultWriter.writeAndShutdown(plugin, success)\n                            }\n                        }\n\n                    val compositeListener =\n                        CompositeTestEngineListener(\n                            listOf(\n                                EnhancedConsoleTestEngineListener(TermColors()),\n                                listener,\n                            ),\n                        )\n\n                    TestEngineLauncher()\n                        .withListener(compositeListener)\n                        .withProjectConfig(KotestProjectConfig)\n                        .withClasses(\n                            ConfigMigrationIntegrationTest::class,\n                            ModesetRulesIntegrationTest::class,\n                            DisableOffhandIntegrationTest::class,\n                            DisableOffhandReflectionIntegrationTest::class,\n                            InGameTesterIntegrationTest::class,\n                            CopperToolsIntegrationTest::class,\n                            OldPotionEffectsIntegrationTest::class,\n                            InvulnerabilityDamageIntegrationTest::class,\n                            FireAspectOverdamageIntegrationTest::class,\n                            OldCriticalHitsIntegrationTest::class,\n                            OldToolDamageMobIntegrationTest::class,\n                            WeaponDurabilityIntegrationTest::class,\n                            GoldenAppleIntegrationTest::class,\n                            OldArmourDurabilityIntegrationTest::class,\n                            PlayerKnockbackIntegrationTest::class,\n                            AttackCooldownTrackerIntegrationTest::class,\n                            AttackCooldownHeldItemIntegrationTest::class,\n                            PlayerRegenIntegrationTest::class,\n                            FishingRodVelocityIntegrationTest::class,\n                            SwordSweepIntegrationTest::class,\n                            PacketCancellationIntegrationTest::class,\n                            EnderpearlCooldownIntegrationTest::class,\n                            SpigotFunctionChooserIntegrationTest::class,\n                            ChorusFruitIntegrationTest::class,\n                            CustomWeaponDamageIntegrationTest::class,\n                            ToolDamageTooltipIntegrationTest::class,\n                            SwordBlockingIntegrationTest::class,\n                            ConsumableComponentIntegrationTest::class,\n                            PaperSwordBlockingDamageReductionIntegrationTest::class,\n                            AttackRangeIntegrationTest::class,\n                        ).launch()\n                } catch (e: Throwable) {\n                    plugin.logger.severe(\"Failed to execute Kotest runner: ${e.message}\")\n                    TestResultWriter.writeAndShutdown(plugin, false, e)\n                }\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/LegacyFakePlayer12.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.mojang.authlib.GameProfile\nimport io.netty.channel.ChannelInboundHandlerAdapter\nimport io.netty.channel.embedded.EmbeddedChannel\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.entity.Player\nimport org.bukkit.event.player.AsyncPlayerPreLoginEvent\nimport org.bukkit.event.player.PlayerJoinEvent\nimport org.bukkit.event.player.PlayerPreLoginEvent\nimport org.bukkit.event.player.PlayerQuitEvent\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.lang.reflect.Field\nimport java.lang.reflect.Method\nimport java.net.InetAddress\nimport java.util.UUID\n\ninternal class LegacyFakePlayer12(\n    private val plugin: JavaPlugin,\n    val uuid: UUID,\n    val name: String\n) {\n    private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')\n    var entityPlayer: Any? = null\n        private set\n    var bukkitPlayer: Player? = null\n        private set\n\n    private fun nmsClass(simpleName: String): Class<*> =\n        Class.forName(\"net.minecraft.server.$cbVersion.$simpleName\")\n\n    private fun craftClass(simpleName: String): Class<*> =\n        Class.forName(\"org.bukkit.craftbukkit.$cbVersion.$simpleName\")\n\n    fun spawn(location: Location) {\n        plugin.logger.info(\"Spawn: Starting (legacy $cbVersion)\")\n        val world = location.world ?: throw IllegalArgumentException(\"Location has no world!\")\n\n        val craftWorld = craftClass(\"CraftWorld\").cast(world)\n        val worldServer = craftWorld.javaClass.getMethod(\"getHandle\").invoke(craftWorld)\n\n        val craftServer = craftClass(\"CraftServer\").cast(Bukkit.getServer())\n        val minecraftServer = craftServer.javaClass.getMethod(\"getServer\").invoke(craftServer)\n\n        val entityPlayer = createEntityPlayer(minecraftServer, worldServer)\n        this.entityPlayer = entityPlayer\n        val bukkitPlayer = entityPlayer.javaClass.getMethod(\"getBukkitEntity\").invoke(entityPlayer) as Player\n        this.bukkitPlayer = bukkitPlayer\n\n        firePreLoginEvents()\n\n        val playerList = minecraftServer.javaClass.getMethod(\"getPlayerList\").invoke(minecraftServer)\n        invokeMethodIfExists(playerList, \"a\", entityPlayer)\n\n        setPositionRotation(entityPlayer, location)\n        setDataWatcherFlags(entityPlayer)\n\n        spawnInWorld(entityPlayer, worldServer)\n        setGameMode(entityPlayer, worldServer)\n        setupConnection(entityPlayer, minecraftServer)\n\n        addToPlayerChunkMap(worldServer, entityPlayer)\n        addToPlayerList(playerList, entityPlayer)\n        updatePlayerListMaps(playerList, entityPlayer)\n\n        val joinMessage = \"§e$name joined the game\"\n        val joinEvent = PlayerJoinEvent(bukkitPlayer, joinMessage)\n        Bukkit.getPluginManager().callEvent(joinEvent)\n        broadcastMessage(joinEvent.joinMessage)\n\n        sendSpawnPackets(entityPlayer)\n        addEntityToWorld(worldServer, entityPlayer)\n\n        Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {\n            runCatching { invokeMethod(entityPlayer, \"playerTick\") }\n        }, 1L, 1L)\n\n        plugin.logger.info(\"Spawn: completed successfully (legacy)\")\n    }\n\n    fun removePlayer() {\n        val entityPlayer = entityPlayer ?: return\n        val bukkitPlayer = bukkitPlayer ?: return\n\n        val craftServer = craftClass(\"CraftServer\").cast(Bukkit.getServer())\n        val minecraftServer = craftServer.javaClass.getMethod(\"getServer\").invoke(craftServer)\n        val playerList = minecraftServer.javaClass.getMethod(\"getPlayerList\").invoke(minecraftServer)\n\n        val quitMessage = \"§e$name left the game\"\n        val quitEvent = PlayerQuitEvent(bukkitPlayer, quitMessage)\n        Bukkit.getPluginManager().callEvent(quitEvent)\n\n        val worldServer = getWorldServer(entityPlayer)\n        val playerChunkMap = getPlayerChunkMap(worldServer)\n        invokeMethodIfExists(playerChunkMap, \"removePlayer\", entityPlayer)\n        invokeMethodIfExists(worldServer, \"removeEntity\", entityPlayer)\n\n        bukkitPlayer.kickPlayer(quitMessage)\n\n        removeFromPlayerList(playerList, entityPlayer)\n        removeFromPlayerMaps(playerList, entityPlayer)\n\n        sendRemovePackets(entityPlayer)\n\n        invokeMethodIfExists(playerList, \"savePlayerFile\", entityPlayer)\n    }\n\n    fun startUsingOffhand() {\n        val entityPlayer = entityPlayer ?: return\n        val enumHandClass = nmsClass(\"EnumHand\")\n        val offHand = enumValue(enumHandClass, \"OFF_HAND\")\n        val candidateNames = arrayOf(\"c\", \"a\", \"startUsingItem\")\n        for (name in candidateNames) {\n            val method = findMethod(entityPlayer.javaClass, name, arrayOf(offHand), ignoreMissing = true)\n            if (method != null) {\n                method.invoke(entityPlayer, offHand)\n                return\n            }\n        }\n        val fallback = entityPlayer.javaClass.methods.firstOrNull { method ->\n            method.parameterTypes.size == 1 && method.parameterTypes[0] == enumHandClass\n        }\n        fallback?.invoke(entityPlayer, offHand)\n    }\n\n    fun getConnection(serverPlayer: Any): Any {\n        val connectionField = findField(serverPlayer.javaClass, \"playerConnection\")\n            ?: throw NoSuchFieldException(\"playerConnection not found on ${serverPlayer.javaClass.name}\")\n        return connectionField.get(serverPlayer)\n    }\n\n    private fun createEntityPlayer(minecraftServer: Any, worldServer: Any): Any {\n        val entityPlayerClass = nmsClass(\"EntityPlayer\")\n        val playerInteractManagerClass = nmsClass(\"PlayerInteractManager\")\n        val pimCtor = playerInteractManagerClass.constructors.firstOrNull { ctor ->\n            ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)\n        } ?: playerInteractManagerClass.constructors.first()\n        val playerInteractManager = pimCtor.newInstance(worldServer)\n        val gameProfile = GameProfile(uuid, name)\n        val ctor = entityPlayerClass.constructors.firstOrNull { ctor ->\n            val params = ctor.parameterTypes\n            params.size == 4 &&\n                params[0].isAssignableFrom(minecraftServer.javaClass) &&\n                params[1].isAssignableFrom(worldServer.javaClass) &&\n                params[2] == GameProfile::class.java &&\n                params[3].isAssignableFrom(playerInteractManagerClass)\n        } ?: entityPlayerClass.constructors.first()\n        return ctor.newInstance(minecraftServer, worldServer, gameProfile, playerInteractManager)\n    }\n\n    private fun firePreLoginEvents() {\n        try {\n            val address = InetAddress.getByName(\"127.0.0.1\")\n            val asyncPreLogin = AsyncPlayerPreLoginEvent(name, address, uuid)\n            val preLogin = PlayerPreLoginEvent(name, address, uuid)\n            Thread { Bukkit.getPluginManager().callEvent(asyncPreLogin) }.start()\n            Bukkit.getPluginManager().callEvent(preLogin)\n        } catch (e: Exception) {\n            plugin.logger.warning(\"Failed to fire pre-login events: ${e.message}\")\n        }\n    }\n\n    private fun setPositionRotation(entityPlayer: Any, location: Location) {\n        val method = entityPlayer.javaClass.getMethod(\n            \"setPositionRotation\",\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType,\n            Float::class.javaPrimitiveType,\n            Float::class.javaPrimitiveType\n        )\n        method.invoke(\n            entityPlayer,\n            location.x,\n            location.y,\n            location.z,\n            location.yaw,\n            location.pitch\n        )\n    }\n\n    private fun setDataWatcherFlags(entityPlayer: Any) {\n        runCatching {\n            val dataWatcher = invokeMethod(entityPlayer, \"getDataWatcher\")\n            val dataWatcherRegistryClass = nmsClass(\"DataWatcherRegistry\")\n            val serializer = dataWatcherRegistryClass.getField(\"a\").get(null)\n            val dataWatcherObject = invokeMethod(serializer, \"a\", 13)\n            val setMethod = dataWatcher.javaClass.methods.firstOrNull { it.name == \"set\" && it.parameterCount == 2 }\n            setMethod?.invoke(dataWatcher, dataWatcherObject, 127.toByte())\n        }\n    }\n\n    private fun spawnInWorld(entityPlayer: Any, worldServer: Any) {\n        invokeMethodIfExists(entityPlayer, \"spawnIn\", worldServer)\n        val playerInteractManager = findField(entityPlayer.javaClass, \"playerInteractManager\")?.get(entityPlayer)\n        if (playerInteractManager != null) {\n            invokeMethodIfExists(playerInteractManager, \"a\", worldServer)\n        }\n    }\n\n    private fun setGameMode(entityPlayer: Any, worldServer: Any) {\n        val playerInteractManager = findField(entityPlayer.javaClass, \"playerInteractManager\")?.get(entityPlayer) ?: return\n        val enumGamemodeClass = nmsClass(\"EnumGamemode\")\n        val gameMode = Bukkit.getServer().defaultGameMode\n        val enumValue = enumValue(enumGamemodeClass, gameMode)\n        invokeMethodIfExists(playerInteractManager, \"b\", enumValue)\n    }\n\n    private fun setupConnection(entityPlayer: Any, minecraftServer: Any) {\n        val networkManagerClass = nmsClass(\"NetworkManager\")\n        val enumProtocolDirectionClass = nmsClass(\"EnumProtocolDirection\")\n        val serverbound = enumValue(enumProtocolDirectionClass, \"SERVERBOUND\")\n        val networkManager = networkManagerClass\n            .getConstructor(enumProtocolDirectionClass)\n            .newInstance(serverbound)\n\n        val playerConnectionClass = nmsClass(\"PlayerConnection\")\n        val pcCtor = playerConnectionClass.constructors.firstOrNull { ctor ->\n            val params = ctor.parameterTypes\n            params.size == 3 &&\n                params[0].isAssignableFrom(minecraftServer.javaClass) &&\n                params[1].isAssignableFrom(networkManagerClass) &&\n                params[2].isAssignableFrom(entityPlayer.javaClass)\n        } ?: playerConnectionClass.constructors.first()\n        val playerConnection = pcCtor.newInstance(minecraftServer, networkManager, entityPlayer)\n\n        setFieldValue(entityPlayer, \"playerConnection\", playerConnection)\n        val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())\n        setFieldValue(networkManager, \"channel\", channel)\n        runCatching { channel.close() }\n    }\n\n    private fun addToPlayerChunkMap(worldServer: Any, entityPlayer: Any) {\n        val playerChunkMap = getPlayerChunkMap(worldServer)\n        invokeMethodIfExists(playerChunkMap, \"addPlayer\", entityPlayer)\n    }\n\n    private fun addToPlayerList(playerList: Any, entityPlayer: Any) {\n        runCatching {\n            val playersField = findField(playerList.javaClass, \"players\")\n            @Suppress(\"UNCHECKED_CAST\") val players = playersField?.get(playerList) as? MutableCollection<Any>\n            players?.add(entityPlayer)\n        }\n    }\n\n    private fun updatePlayerListMaps(playerList: Any, entityPlayer: Any) {\n        runCatching {\n            val byUuidField = findField(playerList.javaClass, \"j\")\n            @Suppress(\"UNCHECKED_CAST\") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>\n            byUuid?.put(uuid, entityPlayer)\n        }\n        runCatching {\n            val byNameField = findField(playerList.javaClass, \"playersByName\")\n            @Suppress(\"UNCHECKED_CAST\") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>\n            byName?.put(name, entityPlayer)\n        }\n    }\n\n    private fun sendSpawnPackets(entityPlayer: Any) {\n        val packetPlayOutPlayerInfo = nmsClass(\"PacketPlayOutPlayerInfo\")\n        val actionClass = nmsClass(\"PacketPlayOutPlayerInfo\\$EnumPlayerInfoAction\")\n        val addAction = enumValue(actionClass, \"ADD_PLAYER\")\n        val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)\n        java.lang.reflect.Array.set(entityArray, 0, entityPlayer)\n        val infoPacket = packetPlayOutPlayerInfo\n            .getConstructor(actionClass, entityArray.javaClass)\n            .newInstance(addAction, entityArray)\n\n        val namedSpawnPacket = createSingleArgPacket(\"PacketPlayOutNamedEntitySpawn\", entityPlayer)\n\n        val craftPlayerClass = craftClass(\"entity.CraftPlayer\")\n        for (player in Bukkit.getOnlinePlayers()) {\n            val craftPlayer = craftPlayerClass.cast(player)\n            val handle = craftPlayerClass.getMethod(\"getHandle\").invoke(craftPlayer)\n            val connection = findField(handle.javaClass, \"playerConnection\")?.get(handle) ?: continue\n            sendPacket(connection, infoPacket)\n            if (namedSpawnPacket != null) {\n                sendPacket(connection, namedSpawnPacket)\n            }\n        }\n    }\n\n    private fun sendRemovePackets(entityPlayer: Any) {\n        val packetDestroy = createEntityDestroyPacket(entityPlayer)\n        val packetInfo = createPlayerInfoPacket(\"REMOVE_PLAYER\", entityPlayer)\n        val craftPlayerClass = craftClass(\"entity.CraftPlayer\")\n        for (player in Bukkit.getOnlinePlayers()) {\n            val craftPlayer = craftPlayerClass.cast(player)\n            val handle = craftPlayerClass.getMethod(\"getHandle\").invoke(craftPlayer)\n            val connection = findField(handle.javaClass, \"playerConnection\")?.get(handle) ?: continue\n            if (packetDestroy != null) sendPacket(connection, packetDestroy)\n            if (packetInfo != null) sendPacket(connection, packetInfo)\n        }\n    }\n\n    private fun createPlayerInfoPacket(actionName: String, entityPlayer: Any): Any? {\n        return runCatching {\n            val packetPlayOutPlayerInfo = nmsClass(\"PacketPlayOutPlayerInfo\")\n            val actionClass = nmsClass(\"PacketPlayOutPlayerInfo\\$EnumPlayerInfoAction\")\n            val action = enumValue(actionClass, actionName)\n            val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)\n            java.lang.reflect.Array.set(entityArray, 0, entityPlayer)\n            packetPlayOutPlayerInfo\n                .getConstructor(actionClass, entityArray.javaClass)\n                .newInstance(action, entityArray)\n        }.getOrNull()\n    }\n\n    private fun createEntityDestroyPacket(entityPlayer: Any): Any? {\n        return runCatching {\n            val packetClass = nmsClass(\"PacketPlayOutEntityDestroy\")\n            val getIdMethod = entityPlayer.javaClass.getMethod(\"getId\")\n            val entityId = getIdMethod.invoke(entityPlayer) as Int\n            val ids = intArrayOf(entityId)\n            packetClass.getConstructor(IntArray::class.java).newInstance(ids)\n        }.getOrNull()\n    }\n\n    private fun createSingleArgPacket(className: String, entityPlayer: Any): Any? {\n        return runCatching {\n            val packetClass = nmsClass(className)\n            val ctor = packetClass.constructors.firstOrNull { ctor ->\n                ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass)\n            } ?: packetClass.constructors.firstOrNull { ctor ->\n                ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass.superclass)\n            }\n            ctor?.newInstance(entityPlayer)\n        }.getOrNull()\n    }\n\n    private fun addEntityToWorld(worldServer: Any, entityPlayer: Any) {\n        invokeMethodIfExists(worldServer, \"addEntity\", entityPlayer)\n    }\n\n    private fun getWorldServer(entityPlayer: Any): Any {\n        val worldField = findField(entityPlayer.javaClass, \"world\")\n        return worldField?.get(entityPlayer) ?: invokeMethod(entityPlayer, \"getWorld\")\n    }\n\n    private fun getPlayerChunkMap(worldServer: Any): Any {\n        val getPlayerChunkMap = worldServer.javaClass.methods.firstOrNull { it.name == \"getPlayerChunkMap\" }\n        return if (getPlayerChunkMap != null) {\n            getPlayerChunkMap.invoke(worldServer)\n        } else {\n            findField(worldServer.javaClass, \"playerChunkMap\")?.get(worldServer)\n                ?: throw NoSuchFieldException(\"playerChunkMap not found on ${worldServer.javaClass.name}\")\n        }\n    }\n\n    private fun removeFromPlayerList(playerList: Any, entityPlayer: Any) {\n        runCatching {\n            val playersField = findField(playerList.javaClass, \"players\")\n            @Suppress(\"UNCHECKED_CAST\") val players = playersField?.get(playerList) as? MutableCollection<Any>\n            players?.remove(entityPlayer)\n        }\n    }\n\n    private fun removeFromPlayerMaps(playerList: Any, entityPlayer: Any) {\n        runCatching {\n            val byUuidField = findField(playerList.javaClass, \"j\")\n            @Suppress(\"UNCHECKED_CAST\") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>\n            byUuid?.remove(uuid)\n        }\n        runCatching {\n            val byNameField = findField(playerList.javaClass, \"playersByName\")\n            @Suppress(\"UNCHECKED_CAST\") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>\n            byName?.remove(name)\n        }\n    }\n\n    private fun sendPacket(connection: Any, packet: Any) {\n        val packetClass = nmsClass(\"Packet\")\n        val sendMethod = connection.javaClass.getMethod(\"sendPacket\", packetClass)\n        sendMethod.invoke(connection, packet)\n    }\n\n    private fun broadcastMessage(message: String?) {\n        if (message.isNullOrEmpty()) return\n        for (player in Bukkit.getOnlinePlayers()) {\n            player.sendMessage(message)\n        }\n    }\n\n    private fun invokeMethod(target: Any, name: String, vararg args: Any?): Any {\n        val method = findMethod(target.javaClass, name, args)\n            ?: throw NoSuchMethodException(\"Method '$name' not found on ${target.javaClass.name}\")\n        return method.invoke(target, *args)\n    }\n\n    private fun invokeMethodIfExists(target: Any, name: String, vararg args: Any?) {\n        val method = findMethod(target.javaClass, name, args, ignoreMissing = true) ?: return\n        method.invoke(target, *args)\n    }\n\n    private fun findMethod(\n        clazz: Class<*>,\n        name: String,\n        args: Array<out Any?>,\n        ignoreMissing: Boolean = false\n    ): Method? {\n        val candidates = (clazz.methods + clazz.declaredMethods).filter { it.name == name }\n        val method = candidates.firstOrNull { method ->\n            if (method.parameterTypes.size != args.size) return@firstOrNull false\n            method.parameterTypes.indices.all { idx ->\n                val arg = args[idx]\n                arg == null || method.parameterTypes[idx].isAssignableFrom(arg.javaClass)\n            }\n        } ?: candidates.firstOrNull { it.parameterTypes.size == args.size }\n        if (method == null && !ignoreMissing) {\n            throw NoSuchMethodException(\"Method '$name' not found on ${clazz.name}\")\n        }\n        method?.isAccessible = true\n        return method\n    }\n\n    private fun findField(clazz: Class<*>, name: String): Field? {\n        var current: Class<*>? = clazz\n        while (current != null) {\n            runCatching {\n                val field = current.getDeclaredField(name)\n                field.isAccessible = true\n                return field\n            }\n            current = current.superclass\n        }\n        return null\n    }\n\n    private fun setFieldValue(target: Any, name: String, value: Any?) {\n        val field = findField(target.javaClass, name) ?: return\n        field.set(target, value)\n    }\n\n    private fun enumValue(enumClass: Class<*>, name: String): Any {\n        @Suppress(\"UNCHECKED_CAST\")\n        val enumType = enumClass as Class<out Enum<*>>\n        return java.lang.Enum.valueOf(enumType, name)\n    }\n\n    private fun enumValue(enumClass: Class<*>, gameMode: GameMode): Any {\n        return enumValue(enumClass, gameMode.name)\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/LegacyFakePlayer9.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.mojang.authlib.GameProfile\nimport io.netty.channel.ChannelInboundHandlerAdapter\nimport io.netty.channel.ChannelOutboundHandlerAdapter\nimport io.netty.channel.embedded.EmbeddedChannel\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.entity.Player\nimport org.bukkit.event.player.AsyncPlayerPreLoginEvent\nimport org.bukkit.event.player.PlayerPreLoginEvent\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.net.InetAddress\nimport java.util.*\n\n/**\n * Fake player for 1.9.x (v1_9_R2). Uses PlayerList#a(NetworkManager, EntityPlayer)\n * so the server treats it like a real online player (damage/knockback events).\n */\ninternal class LegacyFakePlayer9(\n    private val plugin: JavaPlugin,\n    val uuid: UUID,\n    val name: String\n) {\n    private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')\n    var entityPlayer: Any? = null\n        private set\n    var bukkitPlayer: Player? = null\n        private set\n\n    private fun nms(simple: String): Class<*> =\n        Class.forName(\"net.minecraft.server.$cbVersion.$simple\", true, Bukkit.getServer().javaClass.classLoader)\n    private fun craft(simple: String): Class<*> =\n        Class.forName(\"org.bukkit.craftbukkit.$cbVersion.$simple\", true, Bukkit.getServer().javaClass.classLoader)\n\n    fun spawn(location: Location) {\n        val world = location.world ?: error(\"Location has no world\")\n        val craftWorld = craft(\"CraftWorld\").cast(world)\n        val worldServer = craftWorld.javaClass.getMethod(\"getHandle\").invoke(craftWorld)\n\n        val craftServer = craft(\"CraftServer\").cast(Bukkit.getServer())\n        val mcServer = craftServer.javaClass.getMethod(\"getServer\").invoke(craftServer)\n        val playerList = mcServer.javaClass.getMethod(\"getPlayerList\").invoke(mcServer)\n\n        val ep = createEntityPlayer(mcServer, worldServer)\n        entityPlayer = ep\n        bukkitPlayer = ep.javaClass.getMethod(\"getBukkitEntity\").invoke(ep) as Player\n\n        // Ensure NMS alive/dead flags are sane so Bukkit sees the player as valid.\n        runCatching {\n            val deadField = ep.javaClass.superclass.getDeclaredField(\"dead\")\n            deadField.isAccessible = true\n            deadField.setBoolean(ep, false)\n        }\n        runCatching {\n            val health = ep.javaClass.getMethod(\"setHealth\", Float::class.javaPrimitiveType)\n            health.invoke(ep, 20.0f)\n        }\n\n        firePreLogin()\n        setPosition(ep, location)\n        setGameMode(ep)\n        setVulnerability(ep, false)\n        setMetaDefaults(ep)\n\n        val nm = setupConnection(ep, mcServer)\n\n        // Run the real join pipeline\n        playerList.javaClass.getMethod(\"a\", nms(\"NetworkManager\"), nms(\"EntityPlayer\"))\n            .apply { isAccessible = true }\n            .invoke(playerList, nm, ep)\n\n        // Clean up the duplicate UUID warning: ensure we don't re-add if PlayerList already did\n        runCatching {\n            val playersField = playerList.javaClass.getDeclaredField(\"players\")\n            playersField.isAccessible = true\n            val list = playersField.get(playerList) as MutableList<Any>\n            if (!list.contains(ep)) list.add(ep)\n        }\n\n        // Ensure CraftServer maps see the player (for Bukkit.getPlayer)\n        updateCraftMaps(craftServer, bukkitPlayer!!)\n\n        // Force entity into world lists (safety)\n        runCatching { worldServer.javaClass.getMethod(\"addEntity\", nms(\"Entity\")).invoke(worldServer, ep) }\n\n        // Ensure chunk tracking in case join path skipped it\n        runCatching {\n            val pcm = worldServer.javaClass.getMethod(\"getPlayerChunkMap\").invoke(worldServer)\n            pcm.javaClass.methods.firstOrNull { it.name == \"addPlayer\" && it.parameterCount == 1 }?.invoke(pcm, ep)\n            pcm.javaClass.methods.firstOrNull { it.name == \"movePlayer\" && it.parameterCount == 1 }?.invoke(pcm, ep)\n        }\n\n        // Broadcast spawn packets (ADD_PLAYER + NamedEntitySpawn) to online players\n        runCatching {\n            val packetInfoClass = nms(\"PacketPlayOutPlayerInfo\")\n            val enumInfo = packetInfoClass.declaredClasses.first { it.simpleName.contains(\"EnumPlayerInfoAction\") }\n            val addPlayer = enumInfo.getField(\"ADD_PLAYER\").get(null)\n            val epArray = java.lang.reflect.Array.newInstance(nms(\"EntityPlayer\"), 1).apply {\n                java.lang.reflect.Array.set(this, 0, ep)\n            }\n            val infoCtor = packetInfoClass.getConstructor(enumInfo, epArray.javaClass)\n            val infoPacket = infoCtor.newInstance(addPlayer, epArray)\n\n            val spawnPacketClass = nms(\"PacketPlayOutNamedEntitySpawn\")\n            val spawnCtor = spawnPacketClass.getConstructor(nms(\"EntityHuman\"))\n            val spawnPacket = spawnCtor.newInstance(ep)\n\n            val attrPacketClass = nms(\"PacketPlayOutUpdateAttributes\")\n            val attrCtor = attrPacketClass.getConstructor(nms(\"EntityLiving\"), java.util.Collection::class.java)\n            val attrMethod = ep.javaClass.methods.firstOrNull { it.name == \"getAttributeInstance\" }\n            val attributes = java.util.ArrayList<Any>()\n            // health and attack damage attributes if available\n            runCatching {\n                val generic = Class.forName(\"org.bukkit.attribute.Attribute\")\n                val healthEnum = generic.getField(\"GENERIC_MAX_HEALTH\").get(null)\n                val dmgEnum = generic.getField(\"GENERIC_ATTACK_DAMAGE\").get(null)\n                val attrBase = ep.javaClass.methods.firstOrNull { it.name == \"getAttributeInstance\" && it.parameterTypes.size == 1 }\n                val healthInst = attrBase?.invoke(ep, nms(\"GenericAttributes\").getField(\"MAX_HEALTH\").get(null))\n                val dmgInst = attrBase?.invoke(ep, nms(\"GenericAttributes\").getField(\"ATTACK_DAMAGE\").get(null))\n                if (healthInst != null) attributes.add(healthInst)\n                if (dmgInst != null) attributes.add(dmgInst)\n            }\n            val attrPacket = runCatching { attrCtor.newInstance(ep, attributes) }.getOrNull()\n\n            val heldSlotClass = nms(\"PacketPlayOutHeldItemSlot\")\n            val heldSlotPacket = runCatching { heldSlotClass.getConstructor(Int::class.javaPrimitiveType).newInstance(0) }.getOrNull()\n\n            val windowItemsClass = nms(\"PacketPlayOutWindowItems\")\n            val inventory = ep.javaClass.getField(\"inventory\").get(ep)\n            val getContents = inventory.javaClass.methods.firstOrNull { it.name == \"getContents\" && it.parameterCount == 0 }\n            val contents = runCatching { getContents?.invoke(inventory) as? Array<Any> }.getOrNull()\n            val windowPacket = runCatching { windowItemsClass.constructors.first().newInstance(0, listOf(*contents ?: emptyArray())) }.getOrNull()\n\n            val healthClass = nms(\"PacketPlayOutUpdateHealth\")\n            val healthPacket = runCatching {\n                val health = ep.javaClass.getMethod(\"getHealth\").invoke(ep) as Float\n                val food = ep.javaClass.getMethod(\"getFoodData\").invoke(ep)\n                val foodLevel = food.javaClass.getMethod(\"getFoodLevel\").invoke(food) as Int\n                val saturation = food.javaClass.getMethod(\"getSaturationLevel\").invoke(food) as Float\n                healthClass.getConstructor(Float::class.javaPrimitiveType, Int::class.javaPrimitiveType, Float::class.javaPrimitiveType)\n                    .newInstance(health, foodLevel, saturation)\n            }.getOrNull()\n\n            val metaClass = nms(\"PacketPlayOutEntityMetadata\")\n            val metaCtor = metaClass.getConstructor(Int::class.javaPrimitiveType, nms(\"DataWatcher\"), Boolean::class.javaPrimitiveType)\n            val dataWatcher = ep.javaClass.getMethod(\"getDataWatcher\").invoke(ep)\n            val metaPacket = metaCtor.newInstance(ep.javaClass.getMethod(\"getId\").invoke(ep) as Int, dataWatcher, true)\n\n            val animClass = nms(\"PacketPlayOutAnimation\")\n            val swingPacket = runCatching { animClass.getConstructor(nms(\"Entity\"), Int::class.javaPrimitiveType).newInstance(ep, 0) }.getOrNull()\n\n            Bukkit.getOnlinePlayers().forEach { viewer ->\n                val handle = viewer.javaClass.getMethod(\"getHandle\").invoke(viewer)\n                val conn = handle.javaClass.getField(\"playerConnection\").get(handle)\n                val send = conn.javaClass.methods.first { it.name == \"sendPacket\" && it.parameterTypes.size == 1 }\n                send.invoke(conn, infoPacket)\n                send.invoke(conn, spawnPacket)\n                if (attrPacket != null) send.invoke(conn, attrPacket)\n                if (heldSlotPacket != null) send.invoke(conn, heldSlotPacket)\n                if (windowPacket != null) send.invoke(conn, windowPacket)\n                if (healthPacket != null) send.invoke(conn, healthPacket)\n                send.invoke(conn, metaPacket)\n                if (swingPacket != null) send.invoke(conn, swingPacket)\n            }\n        }\n\n        // Tick task to keep status/effects progressing\n        Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {\n            runCatching {\n                // playerTick is \"m\" in 1.9; fall back to \"n\" if obf differs\n                val tick = ep.javaClass.methods.firstOrNull { it.name == \"m\" && it.parameterCount == 0 }\n                    ?: ep.javaClass.methods.firstOrNull { it.name == \"n\" && it.parameterCount == 0 }\n                    ?: ep.javaClass.methods.firstOrNull { it.name == \"playerTick\" && it.parameterCount == 0 }\n                tick?.invoke(ep)\n                // Keep entity alive/valid flags cleared so Bukkit reports the player as valid\n                runCatching {\n                    val deadField = ep.javaClass.superclass.getDeclaredField(\"dead\")\n                    deadField.isAccessible = true\n                    deadField.setBoolean(ep, false)\n                }\n                runCatching {\n                    val craftEntity = Class.forName(\"org.bukkit.craftbukkit.$cbVersion.entity.CraftEntity\")\n                    val validField = craftEntity.getDeclaredField(\"valid\")\n                    validField.isAccessible = true\n                    validField.setBoolean(bukkitPlayer, true)\n                }\n            }\n            // keep chunk tracking fresh\n            runCatching {\n                val worldServer = ep.javaClass.getMethod(\"getWorld\").invoke(ep)\n                val pcm = worldServer.javaClass.getMethod(\"getPlayerChunkMap\").invoke(worldServer)\n                pcm.javaClass.methods.firstOrNull { it.name == \"movePlayer\" && it.parameterCount == 1 }?.invoke(pcm, ep)\n            }\n            // Apply queued effects/fire ticks\n            runCatching {\n                // Force entity base tick for fire/water checks\n                ep.javaClass.methods.firstOrNull { it.name == \"ae\" && it.parameterCount == 0 } // baseTick in 1.9 obf\n                    ?.invoke(ep)\n            }\n            // Ensure water extinguishes burning for fake players on legacy\n            runCatching {\n                val bp = bukkitPlayer\n                if (bp != null && bp.fireTicks > 0 && bp.location.block.isLiquid) {\n                    bp.fireTicks = 0\n                }\n            }\n        }, 1L, 1L)\n    }\n\n    fun removePlayer() {\n        val ep = entityPlayer ?: return\n        val bp = bukkitPlayer ?: return\n        val craftServer = craft(\"CraftServer\").cast(Bukkit.getServer())\n        val mcServer = craftServer.javaClass.getMethod(\"getServer\").invoke(craftServer)\n        val playerList = mcServer.javaClass.getMethod(\"getPlayerList\").invoke(mcServer)\n\n        bp.kickPlayer(\"§e$name left the game\")\n        runCatching {\n            playerList.javaClass.getMethod(\"disconnect\", nms(\"EntityPlayer\")).invoke(playerList, ep)\n        }.onFailure {\n            runCatching { playerList.javaClass.getMethod(\"remove\", nms(\"EntityPlayer\")).invoke(playerList, ep) }\n        }\n        runCatching {\n            val pcm = getPlayerChunkMap(ep)\n            pcm?.javaClass?.methods?.firstOrNull { it.name == \"removePlayer\" && it.parameterCount == 1 }?.invoke(pcm, ep)\n        }\n    }\n\n    fun getConnection(serverPlayer: Any): Any {\n        val field = serverPlayer.javaClass.getField(\"playerConnection\")\n        return field.get(serverPlayer)\n    }\n\n    private fun createEntityPlayer(mcServer: Any, worldServer: Any): Any {\n        val epClass = nms(\"EntityPlayer\")\n        val pimClass = nms(\"PlayerInteractManager\")\n        // PlayerInteractManager(World | WorldServer)\n        val pimCtor = pimClass.constructors.firstOrNull { ctor ->\n            ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)\n        } ?: pimClass.constructors.first()\n        val pim = pimCtor.newInstance(worldServer)\n        val gp = GameProfile(uuid, name)\n\n        val ctor = epClass.constructors.firstOrNull { ctor ->\n            val p = ctor.parameterTypes\n            p.size == 4 &&\n                p[0].isAssignableFrom(mcServer.javaClass) &&\n                p[1].isAssignableFrom(worldServer.javaClass) &&\n                p[2].isAssignableFrom(GameProfile::class.java) &&\n                p[3].isAssignableFrom(pimClass)\n        } ?: epClass.constructors.first()\n\n        return ctor.newInstance(mcServer, worldServer, gp, pim)\n    }\n\n    private fun setupConnection(ep: Any, mcServer: Any): Any {\n        val nmClass = nms(\"NetworkManager\")\n        val dirClass = nms(\"EnumProtocolDirection\")\n        val clientbound = dirClass.getField(\"CLIENTBOUND\").get(null)\n        val nm = nmClass.getConstructor(dirClass).newInstance(clientbound)\n        // Dummy channel with predictable address\n        val remote = java.net.InetSocketAddress(\"127.0.0.1\", 25565)\n        val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())\n        val pipeline = channel.pipeline()\n        if (pipeline.get(\"decoder\") == null) {\n            pipeline.addLast(\"decoder\", ChannelInboundHandlerAdapter())\n        }\n        if (pipeline.get(\"encoder\") == null) {\n            pipeline.addLast(\"encoder\", ChannelOutboundHandlerAdapter())\n        }\n        nmClass.getField(\"channel\").set(nm, channel)\n        runCatching { nmClass.getField(\"socketAddress\").set(nm, remote) }\n\n        val pcClass = nms(\"PlayerConnection\")\n        val pc = pcClass.getConstructor(nms(\"MinecraftServer\"), nmClass, nms(\"EntityPlayer\"))\n            .newInstance(mcServer, nm, ep)\n        ep.javaClass.getField(\"playerConnection\").set(ep, pc)\n        runCatching {\n            val setListener = nmClass.methods.firstOrNull { it.name == \"setPacketListener\" && it.parameterCount == 1 }\n            setListener?.invoke(nm, pc)\n        }\n        runCatching {\n            val enumProtocol = nms(\"EnumProtocol\")\n            val play = enumProtocol.getField(\"PLAY\").get(null)\n            nmClass.methods.firstOrNull { it.name == \"a\" && it.parameterTypes.singleOrNull() == enumProtocol }\n                ?.invoke(nm, play)\n        }\n        runCatching {\n            nmClass.fields.firstOrNull { it.name == \"isPending\" }?.setBoolean(nm, false)\n        }\n        return nm\n    }\n\n    private fun setPosition(ep: Any, loc: Location) {\n        ep.javaClass.getMethod(\n            \"setPositionRotation\",\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType,\n            Double::class.javaPrimitiveType,\n            Float::class.javaPrimitiveType,\n            Float::class.javaPrimitiveType\n        ).invoke(ep, loc.x, loc.y, loc.z, loc.yaw, loc.pitch)\n    }\n\n    private fun setGameMode(ep: Any) {\n        val gmName = when (Bukkit.getDefaultGameMode()) {\n            GameMode.CREATIVE -> \"CREATIVE\"\n            GameMode.ADVENTURE -> \"ADVENTURE\"\n            GameMode.SPECTATOR -> \"SPECTATOR\"\n            else -> \"SURVIVAL\"\n        }\n        val enumGMClass = nms(\"WorldSettings\\$EnumGamemode\")\n        val enumGM = enumGMClass.getField(gmName).get(null)\n        val pim = ep.javaClass.getField(\"playerInteractManager\").get(ep)\n        // prefer setGameMode / b(EnumGamemode)\n        val method = pim.javaClass.methods.firstOrNull { it.name in listOf(\"setGameMode\", \"b\") && it.parameterTypes.size == 1 }\n            ?: pim.javaClass.getMethod(\"b\", enumGMClass)\n        method.isAccessible = true\n        method.invoke(pim, enumGM)\n    }\n\n    private fun setVulnerability(ep: Any, invulnerable: Boolean) {\n        runCatching { ep.javaClass.getField(\"invulnerableTicks\").setInt(ep, if (invulnerable) 20 else 0) }\n        runCatching { ep.javaClass.getField(\"noDamageTicks\").setInt(ep, if (invulnerable) 20 else 0) }\n        runCatching {\n            val abilities = ep.javaClass.getField(\"abilities\").get(ep)\n            val flags = mapOf(\n                \"isInvulnerable\" to invulnerable,\n                \"isFlying\" to false,\n                \"mayfly\" to false,\n                \"canInstantlyBuild\" to false\n            )\n            flags.forEach { (k, v) ->\n                runCatching { abilities.javaClass.getField(k).setBoolean(abilities, v) }\n            }\n            ep.javaClass.getMethod(\"updateAbilities\").invoke(ep)\n        }\n    }\n\n    private fun setMetaDefaults(ep: Any) {\n        runCatching {\n            val dw = ep.javaClass.getMethod(\"getDataWatcher\").invoke(ep)\n            val serializerRegistry = nms(\"DataWatcherRegistry\")\n            val byteSerializer = serializerRegistry.getField(\"a\").get(null)\n            val floatSerializer = serializerRegistry.getField(\"c\").get(null)\n\n            val dwoClass = nms(\"DataWatcherObject\")\n            val dwoCtor = dwoClass.getConstructor(Int::class.javaPrimitiveType, nms(\"DataWatcherSerializer\"))\n\n            val set = dw.javaClass.methods.first { it.name == \"set\" && it.parameterTypes.size == 2 }\n\n            // flag byte (index 0) -> 0\n            val flag0 = dwoCtor.newInstance(0, byteSerializer)\n            set.invoke(dw, flag0, 0.toByte())\n\n            // health (index 6) -> 20f\n            val healthObj = dwoCtor.newInstance(6, floatSerializer)\n            set.invoke(dw, healthObj, 20.0f)\n        }\n    }\n\n    private fun firePreLogin() {\n        val addr = InetAddress.getLoopbackAddress()\n        val async = AsyncPlayerPreLoginEvent(name, addr, uuid)\n        val sync = PlayerPreLoginEvent(name, addr, uuid)\n        Thread { Bukkit.getPluginManager().callEvent(async) }.start()\n        Bukkit.getPluginManager().callEvent(sync)\n    }\n\n    private fun getChunkProvider(ep: Any): Any? = runCatching {\n        val worldServer = ep.javaClass.getMethod(\"getWorld\").invoke(ep)\n        worldServer.javaClass.getMethod(\"getChunkProviderServer\").invoke(worldServer)\n    }.getOrNull()\n\n    private fun getPlayerChunkMap(ep: Any): Any? = runCatching {\n        val worldServer = ep.javaClass.getMethod(\"getWorld\").invoke(ep)\n        worldServer.javaClass.getMethod(\"getPlayerChunkMap\").invoke(worldServer)\n    }.getOrNull()\n\n    private fun updateCraftMaps(craftServer: Any, player: Player) {\n        runCatching {\n            val playersField = craftServer.javaClass.getDeclaredField(\"players\")\n            playersField.isAccessible = true\n            val map = playersField.get(craftServer) as MutableMap<String, Player>\n            map[player.name.lowercase(Locale.getDefault())] = player\n        }\n        runCatching {\n            val uuidField = craftServer.javaClass.getDeclaredField(\"playersByUUID\")\n            uuidField.isAccessible = true\n            val map = uuidField.get(craftServer) as MutableMap<UUID, Player>\n            map[player.uniqueId] = player\n        }\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ModesetRulesIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.booleans.shouldBeFalse\nimport io.kotest.matchers.booleans.shouldBeTrue\nimport io.kotest.assertions.throwables.shouldThrow\nimport kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.configuration.ConfigurationSection\nimport org.bukkit.entity.Player\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.Locale\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass ModesetRulesIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleDisableOffHand>()\n        .firstOrNull() ?: error(\"ModuleDisableOffHand not registered\")\n    val internalModules = setOf(\n        \"modeset-listener\",\n        \"attack-cooldown-tracker\",\n        \"entity-damage-listener\"\n    )\n    val optionalModules = setOf(\n        \"disable-attack-sounds\",\n        \"disable-sword-sweep-particles\"\n    )\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        setPlayerData(player.uniqueId, playerData)\n    }\n\n    fun snapshotSection(path: String): Any? {\n        val section = ocm.config.getConfigurationSection(path)\n        return section?.getValues(false) ?: ocm.config.get(path)\n    }\n\n    fun restoreSection(path: String, value: Any?) {\n        ocm.config.set(path, null)\n        when (value) {\n            null -> Unit\n            is Map<*, *> -> {\n                @Suppress(\"UNCHECKED_CAST\")\n                ocm.config.createSection(path, value as Map<String, Any?>)\n            }\n            else -> ocm.config.set(path, value)\n        }\n    }\n\n    fun applyConfig(\n        always: List<String>,\n        disabled: List<String>,\n        modesets: Map<String, List<String>>,\n        worldModesets: List<String>\n    ) {\n        ocm.config.set(\"always_enabled_modules\", always)\n        ocm.config.set(\"disabled_modules\", disabled)\n        ocm.config.set(\"modesets\", null)\n        ocm.config.createSection(\"modesets\", modesets)\n        ocm.config.set(\"worlds.world\", worldModesets)\n        ocm.saveConfig()\n        Config.reload()\n    }\n\n    fun completeAlways(\n        always: List<String>,\n        disabled: List<String>,\n        modesets: Map<String, List<String>>\n    ): List<String> {\n        val assigned = HashSet<String>()\n        always.forEach { assigned.add(it.lowercase(Locale.ROOT)) }\n        disabled.forEach { assigned.add(it.lowercase(Locale.ROOT)) }\n        modesets.values.flatten().forEach { assigned.add(it.lowercase(Locale.ROOT)) }\n\n        val filled = LinkedHashSet<String>()\n        filled.addAll(always)\n        ModuleLoader.getModules()\n            .map { it.configName.lowercase(Locale.ROOT) }\n            .sorted()\n            .filterNot { assigned.contains(it) }\n            .filterNot { internalModules.contains(it) }\n            .forEach { filled.add(it) }\n        optionalModules\n            .filterNot { assigned.contains(it) }\n            .forEach { filled.add(it) }\n        return filled.toList()\n    }\n\n    suspend fun withConfig(block: suspend () -> Unit) {\n        val originalAlways = ocm.config.get(\"always_enabled_modules\")\n        val originalDisabled = ocm.config.get(\"disabled_modules\")\n        val originalModesets = snapshotSection(\"modesets\")\n        val originalWorlds = snapshotSection(\"worlds\")\n\n        try {\n            block()\n        } finally {\n            runSync {\n                ocm.config.set(\"always_enabled_modules\", originalAlways)\n                ocm.config.set(\"disabled_modules\", originalDisabled)\n                restoreSection(\"modesets\", originalModesets)\n                restoreSection(\"worlds\", originalWorlds)\n                ocm.saveConfig()\n                Config.reload()\n            }\n        }\n    }\n\n    beforeSpec {\n        runSync {\n            val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakePlayer.removePlayer()\n        }\n    }\n\n    test(\"always-enabled modules apply regardless of modeset\") {\n        withConfig {\n            runSync {\n                applyConfig(\n                    always = completeAlways(\n                        always = listOf(\"disable-offhand\"),\n                        disabled = emptyList(),\n                        modesets = mapOf(\n                            \"old\" to listOf(\"old-golden-apples\"),\n                            \"new\" to listOf(\"old-potion-effects\")\n                        )\n                    ),\n                    disabled = emptyList(),\n                    modesets = mapOf(\n                        \"old\" to listOf(\"old-golden-apples\"),\n                        \"new\" to listOf(\"old-potion-effects\")\n                    ),\n                    worldModesets = listOf(\"old\", \"new\")\n                )\n\n                setModeset(player, \"old\")\n                module.isEnabled(player).shouldBeTrue()\n\n                setModeset(player, \"new\")\n                module.isEnabled(player).shouldBeTrue()\n            }\n        }\n    }\n\n    test(\"disabled modules never apply\") {\n        withConfig {\n            runSync {\n                applyConfig(\n                    always = completeAlways(\n                        always = emptyList(),\n                        disabled = listOf(\"disable-offhand\"),\n                        modesets = mapOf(\n                            \"old\" to listOf(\"old-golden-apples\"),\n                            \"new\" to listOf(\"old-potion-effects\")\n                        )\n                    ),\n                    disabled = listOf(\"disable-offhand\"),\n                    modesets = mapOf(\n                        \"old\" to listOf(\"old-golden-apples\"),\n                        \"new\" to listOf(\"old-potion-effects\")\n                    ),\n                    worldModesets = listOf(\"old\", \"new\")\n                )\n\n                setModeset(player, \"old\")\n                module.isEnabled(player).shouldBeFalse()\n\n                setModeset(player, \"new\")\n                module.isEnabled(player).shouldBeFalse()\n            }\n        }\n    }\n\n    test(\"modeset membership controls module activation\") {\n        withConfig {\n            runSync {\n                applyConfig(\n                    always = completeAlways(\n                        always = emptyList(),\n                        disabled = emptyList(),\n                        modesets = mapOf(\n                            \"old\" to listOf(\"disable-offhand\"),\n                            \"new\" to listOf(\"old-potion-effects\")\n                        )\n                    ),\n                    disabled = emptyList(),\n                    modesets = mapOf(\n                        \"old\" to listOf(\"disable-offhand\"),\n                        \"new\" to listOf(\"old-potion-effects\")\n                    ),\n                    worldModesets = listOf(\"old\", \"new\")\n                )\n\n                setModeset(player, \"old\")\n                module.isEnabled(player).shouldBeTrue()\n\n                setModeset(player, \"new\")\n                module.isEnabled(player).shouldBeFalse()\n            }\n        }\n    }\n\n    test(\"modules in disabled and another list fail reload\") {\n        withConfig {\n            runSync {\n                shouldThrow<IllegalStateException> {\n                    applyConfig(\n                        always = completeAlways(\n                            always = listOf(\"disable-offhand\"),\n                            disabled = listOf(\"disable-offhand\"),\n                            modesets = mapOf(\n                                \"old\" to listOf(\"old-potion-effects\"),\n                                \"new\" to listOf(\"old-golden-apples\")\n                            )\n                        ),\n                        disabled = listOf(\"disable-offhand\"),\n                        modesets = mapOf(\n                            \"old\" to listOf(\"old-potion-effects\"),\n                            \"new\" to listOf(\"old-golden-apples\")\n                        ),\n                        worldModesets = listOf(\"old\", \"new\")\n                    )\n                }\n            }\n        }\n    }\n\n    test(\"modules missing from all lists fail reload\") {\n        withConfig {\n            runSync {\n                val moduleNames = (ModuleLoader.getModules()\n                    .map { it.configName }\n                    .filterNot { internalModules.contains(it) } + optionalModules)\n                    .distinct()\n                    .sorted()\n                val missing = moduleNames.firstOrNull() ?: error(\"No modules registered\")\n                val always = moduleNames.filterNot { it == missing }\n\n                shouldThrow<IllegalStateException> {\n                    applyConfig(\n                        always = always,\n                        disabled = emptyList(),\n                        modesets = mapOf(\"old\" to emptyList()),\n                        worldModesets = listOf(\"old\")\n                    )\n                }\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OCMTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\n/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics\n\nimport org.bukkit.inventory.ItemStack\n\nclass OCMTest(\n    val weapon: ItemStack,\n    val armour: Array<ItemStack>,\n    val attackDelay: Long,\n    val message: String,\n    val preparations: Runnable\n)\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OCMTestMain.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\nimport org.bukkit.Bukkit\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.lang.reflect.InvocationTargetException\nimport java.util.logging.Level\n\nclass OCMTestMain : JavaPlugin() {\n    override fun onEnable() {\n        logger.info(\"Enabled OCMTest plugin\")\n\n        // Initialise player data storage\n        val ocm = Bukkit.getPluginManager().getPlugin(\"OldCombatMechanics\") as OCMMain\n        PlayerStorage.initialise(ocm)\n\n        val javaVersion = detectJavaVersion()\n        logger.info(\"Detected Java $javaVersion for integration tests\")\n\n        System.setProperty(\"kotest.framework.classpath.scanning.autoscan.disable\", \"true\")\n        runKotest()\n    }\n\n    private fun runKotest() {\n        try {\n            val runnerClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.KotestRunner\")\n            val runMethod = runnerClass.getMethod(\"run\", JavaPlugin::class.java)\n            runMethod.invoke(null, this)\n        } catch (e: InvocationTargetException) {\n            logger.log(Level.SEVERE, \"Failed to launch Kotest runner.\", e.targetException ?: e)\n            TestResultWriter.writeAndShutdown(this, false)\n        } catch (e: Throwable) {\n            logger.log(Level.SEVERE, \"Failed to launch Kotest runner.\", e)\n            TestResultWriter.writeAndShutdown(this, false)\n        }\n    }\n\n    private fun detectJavaVersion(): Int {\n        val version = System.getProperty(\"java.specification.version\") ?: return 0\n        return if (version.startsWith(\"1.\")) {\n            version.substringAfter(\"1.\").toIntOrNull() ?: 0\n        } else {\n            version.toIntOrNull() ?: 0\n        }\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldArmourDurabilityIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleOldArmourDurability\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.player.PlayerItemDamageEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass OldArmourDurabilityIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleOldArmourDurability>()\n        .firstOrNull() ?: error(\"ModuleOldArmourDurability not registered\")\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n        val reduction = ocm.config.getInt(\"old-armour-durability.reduction\")\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"old-armour-durability.reduction\", reduction)\n            module.reload()\n            ModuleLoader.toggleModules()\n        }\n    }\n\n    fun setModeset(modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n    }\n\n    fun createItemDamageEvent(item: ItemStack, damage: Int): PlayerItemDamageEvent {\n        val ctor = PlayerItemDamageEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 4 &&\n                Player::class.java.isAssignableFrom(params[0]) &&\n                ItemStack::class.java.isAssignableFrom(params[1]) &&\n                params[2] == Int::class.javaPrimitiveType &&\n                params[3] == Int::class.javaPrimitiveType\n        }\n        return if (ctor != null) {\n            ctor.newInstance(player, item, damage, damage) as PlayerItemDamageEvent\n        } else {\n            PlayerItemDamageEvent(player, item, damage)\n        }\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val location = Location(world, 0.0, 100.0, 0.0)\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n            player.isOp = true\n            setModeset(\"old\")\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakePlayer.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            player.inventory.clear()\n            player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n            setModeset(\"old\")\n            module.reload()\n        }\n    }\n\n    context(\"Armour durability reduction\") {\n        test(\"worn armour takes reduced durability\") {\n            withConfig {\n                ocm.config.set(\"old-armour-durability.reduction\", 2)\n                module.reload()\n\n                val helmet = ItemStack(Material.DIAMOND_HELMET)\n                player.inventory.helmet = helmet\n\n                val event = createItemDamageEvent(helmet, 5)\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.damage shouldBe 2\n            }\n        }\n\n        test(\"non-armour items are ignored\") {\n            withConfig {\n                ocm.config.set(\"old-armour-durability.reduction\", 2)\n                module.reload()\n\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                player.inventory.setItemInMainHand(sword)\n\n                val event = createItemDamageEvent(sword, 5)\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.damage shouldBe 5\n            }\n        }\n\n        test(\"elytra is ignored\") {\n            withConfig {\n                ocm.config.set(\"old-armour-durability.reduction\", 2)\n                module.reload()\n\n                val elytra = ItemStack(Material.ELYTRA)\n                player.inventory.chestplate = elytra\n\n                val event = createItemDamageEvent(elytra, 5)\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.damage shouldBe 5\n            }\n        }\n    }\n\n    context(\"Explosion handling\") {\n        test(\"explosion damage bypasses durability reduction\") {\n            withConfig {\n                ocm.config.set(\"old-armour-durability.reduction\", 2)\n                module.reload()\n\n                val helmet = ItemStack(Material.DIAMOND_HELMET)\n                player.inventory.helmet = helmet\n\n                val explosion = EntityDamageEvent(player, EntityDamageEvent.DamageCause.BLOCK_EXPLOSION, 6.0)\n                Bukkit.getPluginManager().callEvent(explosion)\n\n                val event = createItemDamageEvent(helmet, 5)\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.damage shouldBe 5\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldCriticalHitsIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XMaterial\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.assertions.withClue\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleOldCriticalHits\nimport kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.util.Vector\nimport java.util.concurrent.Callable\nimport java.util.UUID\nimport kotlin.math.abs\n\n@OptIn(ExperimentalKotest::class)\nclass OldCriticalHitsIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val criticalModule = ModuleLoader.getModules()\n        .filterIsInstance<ModuleOldCriticalHits>()\n        .firstOrNull() ?: error(\"ModuleOldCriticalHits not registered\")\n    val toolDamageModule = ModuleLoader.getModules()\n        .filterIsInstance<ModuleOldToolDamage>()\n        .firstOrNull() ?: error(\"ModuleOldToolDamage not registered\")\n\n    lateinit var attacker: Player\n    lateinit var fakeAttacker: FakePlayer\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    val isLegacy = !Reflector.versionIsNewerOrEqualTo(1, 13, 0)\n    val legacySpeedModifierId = UUID.fromString(\"c1f6010f-4d2e-4b2e-9a2f-3f0d0f1b2e3c\")\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun setOnGround(player: Player, onGround: Boolean) {\n        val handle = player.javaClass.getMethod(\"getHandle\").invoke(player)\n        val field = generateSequence(handle.javaClass) { it.superclass }\n            .mapNotNull { klass ->\n                runCatching { klass.getDeclaredField(\"onGround\") }.getOrNull()\n            }\n            .firstOrNull()\n        field?.let {\n            it.isAccessible = true\n            it.setBoolean(handle, onGround)\n            return\n        }\n        val setOnGroundMethod = generateSequence(handle.javaClass) { it.superclass }\n            .mapNotNull { klass ->\n                runCatching { klass.getDeclaredMethod(\"setOnGround\", Boolean::class.javaPrimitiveType) }.getOrNull()\n            }\n            .firstOrNull()\n        setOnGroundMethod?.let {\n            it.isAccessible = true\n            it.invoke(handle, onGround)\n        }\n    }\n\n    suspend fun delayTicks(ticks: Long) {\n        delay(ticks * 50L)\n    }\n\n    fun prepareWeapon(item: ItemStack) {\n        val meta = item.itemMeta ?: return\n        val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n        val speedModifier = createAttributeModifier(\n            name = \"speed\",\n            amount = 1000.0,\n            operation = AttributeModifier.Operation.ADD_NUMBER,\n            slot = EquipmentSlot.HAND\n        )\n        addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n        item.itemMeta = meta\n    }\n\n    fun applyAttackDamageModifiers(player: Player, item: ItemStack) {\n        if (isLegacy) {\n            val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()\n            val speedAttribute = attackSpeedAttribute?.let { player.getAttribute(it) }\n            speedAttribute\n                ?.modifiers\n                ?.filter { it.uniqueId == legacySpeedModifierId }\n                ?.forEach { speedAttribute.removeModifier(it) }\n            val speedModifier = createAttributeModifier(\n                name = \"ocm-legacy-speed\",\n                amount = 1000.0,\n                operation = AttributeModifier.Operation.ADD_NUMBER,\n                slot = EquipmentSlot.HAND,\n                uuid = legacySpeedModifierId\n            )\n            speedAttribute?.addModifier(speedModifier)\n            return\n        }\n        val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n        val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n        val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n        val expectedAmounts = modifiers\n            .filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }\n            .map { it.amount }\n        val knownWeaponAmounts = NewWeaponDamage.values()\n            .map { it.damage.toDouble() - 1.0 }\n            .filter { it > 0.0 }\n            .toSet()\n\n        fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001\n\n        val existingModifiers = attackAttribute.modifiers.toList()\n        existingModifiers\n            .filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }\n            .filter { modifier ->\n                knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&\n                    expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }\n            }\n            .forEach { attackAttribute.removeModifier(it) }\n\n        modifiers.forEach { modifier ->\n            val alreadyApplied = attackAttribute.modifiers.any {\n                it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)\n            }\n            if (!alreadyApplied) {\n                attackAttribute.addModifier(modifier)\n            }\n        }\n    }\n\n    fun equip(player: Player, item: ItemStack) {\n        if (isLegacy) {\n            // On legacy versions, avoid mutating item meta; directly adjust player attributes instead.\n            val meta = item.itemMeta\n            if (meta != null) {\n                runCatching {\n                    XAttribute.ATTACK_DAMAGE.get()?.let { meta.removeAttributeModifier(it) }\n                    XAttribute.ATTACK_SPEED.get()?.let { meta.removeAttributeModifier(it) }\n                }\n                runCatching { meta.removeAttributeModifier(EquipmentSlot.HAND) }\n                runCatching { meta.removeAttributeModifier(EquipmentSlot.OFF_HAND) }\n                item.itemMeta = meta\n            }\n\n            val useDamageAttribute = !Reflector.versionIsNewerOrEqualTo(1, 12, 0)\n            if (useDamageAttribute) {\n                val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()\n                val damageAttribute = attackDamageAttribute?.let { player.getAttribute(it) }\n                val configuredDamage = WeaponDamages.getDamage(item.type).toDouble().takeIf { it > 0 }\n                    ?: (NewWeaponDamage.getDamageOrNull(item.type) ?: 1.0f).toDouble()\n                damageAttribute?.baseValue = configuredDamage\n            }\n            player.inventory.setItemInMainHand(item)\n            applyAttackDamageModifiers(player, item)\n            player.updateInventory()\n            return\n        }\n\n        prepareWeapon(item)\n        player.inventory.setItemInMainHand(item)\n        applyAttackDamageModifiers(player, item)\n        player.updateInventory()\n    }\n\n    fun spawnVictim(location: Location): LivingEntity {\n        val world = location.world ?: error(\"World missing for victim spawn\")\n        return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {\n            maximumNoDamageTicks = 0\n            noDamageTicks = 0\n            isInvulnerable = false\n            health = maxHealth\n        }\n    }\n\n    suspend fun hitAndCaptureDamage(\n        weapon: ItemStack,\n        critical: Boolean\n    ): Double {\n        val events = mutableListOf<EntityDamageByEntityEvent>()\n        val ocmEvents = mutableListOf<OCMEntityDamageByEntityEvent>()\n        lateinit var victim: LivingEntity\n\n        val listener = object : Listener {\n            @EventHandler\n            fun onDamage(event: EntityDamageByEntityEvent) {\n                if (event.damager.uniqueId == attacker.uniqueId &&\n                    event.entity.uniqueId == victim.uniqueId\n                ) {\n                    events.add(event)\n                    testPlugin.logger.info(\n                        \"Critical hit debug: weapon=${attacker.inventory.itemInMainHand.type} \" +\n                            \"critical=$critical sprinting=${attacker.isSprinting} \" +\n                            \"fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround} \" +\n                            \"damage=${event.damage} finalDamage=${event.finalDamage}\"\n                    )\n                }\n            }\n\n            @EventHandler\n            fun onOcm(event: OCMEntityDamageByEntityEvent) {\n                if (event.damager.uniqueId == attacker.uniqueId &&\n                    event.damagee.uniqueId == victim.uniqueId\n                ) {\n                    ocmEvents.add(event)\n                }\n            }\n        }\n\n        try {\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val victimLocation = Location(world, 1.2, 100.0, 0.0)\n                victim = spawnVictim(victimLocation)\n                Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n\n                equip(attacker, weapon)\n            }\n            delayTicks(1)\n            if (isLegacy) {\n                // Vanilla 1.12 applies attack cooldown scaling before the Bukkit damage event fires.\n                // Give the fake player a short warmup so the baseline (non-critical) hit is not under-scaled.\n                delayTicks(6)\n            }\n            val base = Location(attacker.world, 0.0, 100.0, 0.0)\n            runSync {\n                attacker.teleport(base)\n                attacker.velocity = Vector(0.0, 0.0, 0.0)\n                attacker.isSprinting = false\n                attacker.fallDistance = 0f\n                attacker.updateInventory()\n            }\n\n            if (critical) {\n                // Give the server one tick to recognise the falling state, then re-apply immediately before the swing\n                // so it does not get cleared by ticking (varies by version / fake player internals).\n                runSync {\n                    attacker.isSprinting = true\n                    attacker.teleport(attacker.location.add(0.0, 1.0, 0.0))\n                    attacker.velocity = Vector(0.0, -0.1, 0.0)\n                    attacker.fallDistance = 2f\n                    setOnGround(attacker, false)\n                }\n                delayTicks(1)\n                runSync {\n                    attacker.isSprinting = true\n                    attacker.velocity = Vector(0.0, -0.1, 0.0)\n                    attacker.fallDistance = 2f\n                    setOnGround(attacker, false)\n                    if (!DamageUtils.isCriticalHit1_8(attacker)) {\n                        attacker.fallDistance = 3f\n                        setOnGround(attacker, false)\n                    }\n                    val loc = attacker.location\n                    val dir = victim.location.toVector().subtract(loc.toVector()).normalize()\n                    loc.direction = dir\n                    attacker.teleport(loc)\n                    testPlugin.logger.info(\n                        \"Critical pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}\"\n                    )\n                    attacker.updateInventory()\n                    attackCompat(attacker, victim)\n                }\n            } else {\n                runSync {\n                    attacker.isSprinting = false\n                    attacker.fallDistance = 0f\n                    attacker.velocity = Vector(0.0, 0.0, 0.0)\n                    val loc = attacker.location\n                    val dir = victim.location.toVector().subtract(loc.toVector()).normalize()\n                    loc.direction = dir\n                    attacker.teleport(loc)\n                    testPlugin.logger.info(\n                        \"Normal pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}\"\n                    )\n                    attacker.updateInventory()\n                    attackCompat(attacker, victim)\n                }\n            }\n            delayTicks(4)\n            events.firstOrNull()?.damage?.let { return it }\n            if (critical) {\n                val ocmEvent = ocmEvents.lastOrNull()\n                if (ocmEvent != null && !ocmEvent.was1_8Crit()) {\n                    testPlugin.logger.info(\n                        \"Critical path but was1_8Crit=false; fallDistance=${attacker.fallDistance} \" +\n                            \"onGround=${attacker.isOnGround}\"\n                    )\n                }\n            }\n\n            if (isLegacy) {\n                // As a last resort on legacy, drive damage via Bukkit API to ensure EDBE fires.\n                runSync {\n                    val base = WeaponDamages.getDamage(weapon.type).takeIf { it > 0 } ?: 1.0\n                    victim.damage(base, attacker)\n                }\n                delayTicks(4)\n                events.firstOrNull()?.damage?.let { return it }\n                val healthDelta = (victim.maxHealth - victim.health).toDouble()\n                if (healthDelta > 0.0) return healthDelta\n            }\n\n            error(\"Expected a damage event for critical=$critical\")\n        } finally {\n            HandlerList.unregisterAll(listener)\n            runSync {\n                victim.remove()\n            }\n        }\n    }\n\n    suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n        val critEnabled = ocm.config.getBoolean(\"old-critical-hits.enabled\")\n        val critMultiplier = ocm.config.getDouble(\"old-critical-hits.multiplier\")\n        val critAllowSprinting = ocm.config.getBoolean(\"old-critical-hits.allow-sprinting\")\n        val damagesSection = ocm.config.getConfigurationSection(\"old-tool-damage.damages\")\n        val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()\n\n        fun reloadDamageModules() {\n            WeaponDamages.initialise(ocm)\n            criticalModule.reload()\n            toolDamageModule.reload()\n            ModuleLoader.toggleModules()\n        }\n\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"old-critical-hits.enabled\", critEnabled)\n            ocm.config.set(\"old-critical-hits.multiplier\", critMultiplier)\n            ocm.config.set(\"old-critical-hits.allow-sprinting\", critAllowSprinting)\n            damagesSnapshot.forEach { (key, value) ->\n                ocm.config.set(\"old-tool-damage.damages.$key\", value)\n            }\n            reloadDamageModules()\n        }\n    }\n\n    beforeSpec {\n        runSync {\n            val world = checkNotNull(Bukkit.getWorld(\"world\"))\n            val location = Location(world, 0.0, 100.0, 0.0)\n            fakeAttacker = FakePlayer(testPlugin)\n            fakeAttacker.spawn(location)\n            attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))\n            attacker.gameMode = GameMode.SURVIVAL\n            attacker.isInvulnerable = false\n            attacker.inventory.clear()\n            attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }\n            attacker.isOp = true\n            val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(attacker.uniqueId)\n            playerData.setModesetForWorld(attacker.world.uid, \"old\")\n            kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(attacker.uniqueId, playerData)\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakeAttacker.removePlayer()\n        }\n    }\n\n    test(\"critical hit multiplier applies to customised tool damage\") {\n        withConfig {\n            ocm.config.set(\"old-critical-hits.enabled\", true)\n            ocm.config.set(\"old-critical-hits.multiplier\", 1.5)\n            ocm.config.set(\"old-critical-hits.allow-sprinting\", true)\n            ocm.config.set(\"old-tool-damage.damages.STONE_SWORD\", 10)\n            WeaponDamages.initialise(ocm)\n            criticalModule.reload()\n            toolDamageModule.reload()\n            ModuleLoader.toggleModules()\n\n            val stoneSword = XMaterial.STONE_SWORD.parseItem()\n                ?: error(\"STONE_SWORD material not available\")\n            val normalDamage = hitAndCaptureDamage(stoneSword, critical = false)\n            val criticalDamage = hitAndCaptureDamage(stoneSword, critical = true)\n            withClue(\"normal=$normalDamage critical=$criticalDamage\") {\n                (criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)\n            }\n        }\n    }\n\n    test(\"critical hit multiplier applies when tool damage matches vanilla values\") {\n        withConfig {\n            ocm.config.set(\"old-critical-hits.enabled\", true)\n            ocm.config.set(\"old-critical-hits.multiplier\", 1.5)\n            ocm.config.set(\"old-critical-hits.allow-sprinting\", true)\n            ocm.config.set(\"old-tool-damage.damages.IRON_SWORD\", 6)\n            WeaponDamages.initialise(ocm)\n            criticalModule.reload()\n            toolDamageModule.reload()\n            ModuleLoader.toggleModules()\n\n            val ironSword = XMaterial.IRON_SWORD.parseItem()\n                ?: error(\"IRON_SWORD material not available\")\n            val normalDamage = hitAndCaptureDamage(ironSword, critical = false)\n            val criticalDamage = hitAndCaptureDamage(ironSword, critical = true)\n            withClue(\"normal=$normalDamage critical=$criticalDamage\") {\n                (criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)\n            }\n        }\n    }\n\n    test(\"critical hit multiplier applies to config damage for iron axe\") {\n        withConfig {\n            ocm.config.set(\"old-critical-hits.enabled\", true)\n            ocm.config.set(\"old-critical-hits.multiplier\", 1.5)\n            ocm.config.set(\"old-critical-hits.allow-sprinting\", true)\n            ocm.config.set(\"old-tool-damage.damages.IRON_AXE\", 6)\n            WeaponDamages.initialise(ocm)\n            criticalModule.reload()\n            toolDamageModule.reload()\n            ModuleLoader.toggleModules()\n\n            val ironAxe = XMaterial.IRON_AXE.parseItem()\n                ?: error(\"IRON_AXE material not available\")\n            val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)\n            val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)\n            testPlugin.logger.info(\"Crit debug (cfg=6): normal=$normalDamage critical=$criticalDamage\")\n            withClue(\"normal=$normalDamage critical=$criticalDamage\") {\n                (criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)\n            }\n        }\n    }\n\n    test(\"critical hit multiplies configured iron axe damage\") {\n        withConfig {\n            ocm.config.set(\"old-critical-hits.enabled\", true)\n            ocm.config.set(\"old-critical-hits.multiplier\", 1.25)\n            ocm.config.set(\"old-critical-hits.allow-sprinting\", true)\n            ocm.config.set(\"old-tool-damage.damages.IRON_AXE\", 4.5)\n            WeaponDamages.initialise(ocm)\n            criticalModule.reload()\n            toolDamageModule.reload()\n            ModuleLoader.toggleModules()\n\n            val ironAxe = XMaterial.IRON_AXE.parseItem()\n                ?: error(\"IRON_AXE material not available\")\n            val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)\n            val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)\n            testPlugin.logger.info(\"Crit debug (cfg=4.5): normal=$normalDamage critical=$criticalDamage\")\n            withClue(\"normal=$normalDamage critical=$criticalDamage\") {\n                normalDamage shouldBe (4.5 plusOrMinus 0.05)\n                criticalDamage shouldBe (5.625 plusOrMinus 0.05)\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldPotionEffectsIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.booleans.shouldBeFalse\nimport io.kotest.matchers.booleans.shouldBeTrue\nimport io.kotest.matchers.collections.shouldHaveSize\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.ints.shouldBeExactly\nimport io.kotest.matchers.ints.shouldBeLessThanOrEqual\nimport io.kotest.matchers.shouldBe\nimport io.kotest.matchers.shouldNotBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleOldPotionEffects\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent\nimport com.cryptomorin.xseries.XAttribute\nimport com.cryptomorin.xseries.XMaterial\nimport com.cryptomorin.xseries.XPotion\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.block.BlockFace\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.block.Action\nimport org.bukkit.event.block.BlockDispenseEvent\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.player.PlayerInteractEvent\nimport org.bukkit.event.player.PlayerItemConsumeEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.inventory.meta.PotionMeta\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.potion.PotionData\nimport org.bukkit.potion.PotionEffect\nimport org.bukkit.potion.PotionEffectType\nimport org.bukkit.potion.PotionType\nimport org.bukkit.util.Vector\nimport kotlinx.coroutines.delay\nimport java.util.concurrent.Callable\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\n\n@OptIn(ExperimentalKotest::class)\nclass OldPotionEffectsIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleOldPotionEffects>()\n        .firstOrNull() ?: error(\"ModuleOldPotionEffects not registered\")\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    val excludedPotionTypes = setOf(\n        \"AWKWARD\",\n        \"MUNDANE\",\n        \"THICK\",\n        \"WATER\",\n        \"HARMING\",\n        \"STRONG_HARMING\",\n        \"HEALING\",\n        \"STRONG_HEALING\",\n        \"INSTANT_DAMAGE\",\n        \"INSTANT_HEAL\",\n        \"INSTANT_HEALTH\",\n        \"UNCRAFTABLE\"\n    )\n\n    data class PotionCase(\n        val key: String,\n        val baseName: String,\n        val isStrong: Boolean,\n        val isExtended: Boolean,\n        val potion: XPotion,\n        val drinkableTicks: Int,\n        val splashTicks: Int\n    )\n\n    data class PotionBaseSnapshot(\n        val baseType: PotionType?,\n        val isUpgraded: Boolean,\n        val isExtended: Boolean\n    )\n\n    data class ParsedPotionKey(\n        val baseName: String,\n        val isStrong: Boolean,\n        val isExtended: Boolean,\n        val debugName: String\n    )\n\n    fun debugName(baseName: String, isStrong: Boolean, isExtended: Boolean): String {\n        return when {\n            isStrong -> \"STRONG_$baseName\"\n            isExtended -> \"LONG_$baseName\"\n            else -> baseName\n        }\n    }\n\n    suspend fun waitForAttackReady(attacker: Player) {\n        val cooldownMethod = attacker.javaClass.methods.firstOrNull { method ->\n            method.name == \"getAttackCooldown\" && method.parameterTypes.isEmpty()\n        }\n        if (cooldownMethod == null) {\n            delay(700)\n            return\n        }\n\n        repeat(40) {\n            val value = (cooldownMethod.invoke(attacker) as? Float) ?: 1.0f\n            if (value >= 0.99f) return\n            delay(50)\n        }\n    }\n\n    fun resolveBasePotionType(baseName: String, potion: XPotion): PotionType? {\n        return runCatching { PotionType.valueOf(baseName) }.getOrNull()\n            ?: potion.potionType\n    }\n\n    fun parsePotionKey(key: String): ParsedPotionKey {\n        var name = key.uppercase()\n        var isStrong = false\n        var isExtended = false\n        if (name.startsWith(\"STRONG_\")) {\n            isStrong = true\n            name = name.removePrefix(\"STRONG_\")\n        } else if (name.startsWith(\"LONG_\")) {\n            isExtended = true\n            name = name.removePrefix(\"LONG_\")\n        }\n        val debugName = debugName(name, isStrong, isExtended)\n        return ParsedPotionKey(name, isStrong, isExtended, debugName)\n    }\n\n    val hasBasePotionType = runCatching { PotionMeta::class.java.getMethod(\"getBasePotionType\") }.isSuccess\n\n    fun potionSupports(baseName: String, isStrong: Boolean, isExtended: Boolean, potion: XPotion): Boolean {\n        val potionType = resolveBasePotionType(baseName, potion) ?: return false\n        return if (hasBasePotionType) {\n            val resolvedName = when {\n                isStrong -> \"STRONG_${potionType.name}\"\n                isExtended -> \"LONG_${potionType.name}\"\n                else -> potionType.name\n            }\n            runCatching { PotionType.valueOf(resolvedName) }.isSuccess\n        } else {\n            runCatching { PotionData(potionType, isExtended, isStrong) }.isSuccess\n        }\n    }\n\n    fun loadPotionCases(): List<PotionCase> {\n        val drinkable = ocm.config.getConfigurationSection(\"old-potion-effects.potion-durations.drinkable\")\n            ?: return emptyList()\n        val splash = ocm.config.getConfigurationSection(\"old-potion-effects.potion-durations.splash\")\n            ?: return emptyList()\n\n        return drinkable.getKeys(false).mapNotNull { key ->\n            if (!splash.isInt(key)) return@mapNotNull null\n            val parsed = parsePotionKey(key)\n            val potion = XPotion.of(parsed.baseName).orElse(null) ?: return@mapNotNull null\n            if (excludedPotionTypes.contains(parsed.debugName)) return@mapNotNull null\n            if (!potionSupports(parsed.baseName, parsed.isStrong, parsed.isExtended, potion)) return@mapNotNull null\n            PotionCase(\n                key = key,\n                baseName = parsed.baseName,\n                isStrong = parsed.isStrong,\n                isExtended = parsed.isExtended,\n                potion = potion,\n                drinkableTicks = drinkable.getInt(key) * 20,\n                splashTicks = splash.getInt(key) * 20\n            )\n        }\n    }\n\n    fun createPotionItem(material: Material, potionCase: PotionCase): ItemStack {\n        val item = ItemStack(material)\n        val meta = item.itemMeta as PotionMeta\n        val baseType = resolveBasePotionType(potionCase.baseName, potionCase.potion) ?: return item\n        if (hasBasePotionType) {\n            val resolvedName = when {\n                potionCase.isStrong -> \"STRONG_${baseType.name}\"\n                potionCase.isExtended -> \"LONG_${baseType.name}\"\n                else -> baseType.name\n            }\n            val potionType = runCatching { PotionType.valueOf(resolvedName) }.getOrElse { baseType }\n            meta.basePotionType = potionType\n        } else {\n            meta.basePotionData = PotionData(\n                baseType,\n                potionCase.isExtended,\n                potionCase.isStrong\n            )\n        }\n        item.itemMeta = meta\n        return item\n    }\n\n    fun snapshotBase(meta: PotionMeta): PotionBaseSnapshot {\n        return try {\n            PotionBaseSnapshot(meta.basePotionType, false, false)\n        } catch (e: NoSuchMethodError) {\n            val baseData = meta.basePotionData\n            if (baseData == null) {\n                PotionBaseSnapshot(null, false, false)\n            } else {\n                PotionBaseSnapshot(baseData.type, baseData.isUpgraded, baseData.isExtended)\n            }\n        }\n    }\n\n    fun expectedEffectTypes(potionType: PotionType): List<PotionEffectType> {\n        return try {\n            potionType.potionEffects.map { it.type }\n        } catch (e: NoSuchMethodError) {\n            listOfNotNull(potionType.effectType)\n        }\n    }\n\n    fun expectedAmplifier(baseName: String, isStrong: Boolean): Int {\n        return if (isStrong) 1 else 0\n    }\n\n    fun assertAdjusted(item: ItemStack, baseName: String, isStrong: Boolean, potion: XPotion, expectedTicks: Int) {\n        val meta = item.itemMeta as PotionMeta\n        val potionType = resolveBasePotionType(baseName, potion) ?: error(\"Potion type missing for $baseName\")\n        val expectedTypes = expectedEffectTypes(potionType)\n        val expectedAmp = expectedAmplifier(baseName, isStrong)\n\n        meta.customEffects.shouldHaveSize(expectedTypes.size)\n        expectedTypes.forEach { effectType ->\n            val effect = meta.customEffects.firstOrNull { it.type == effectType }\n            effect.shouldNotBe(null)\n            effect!!.duration.shouldBeExactly(expectedTicks)\n            if (baseName == \"WEAKNESS\") {\n                effect.amplifier.shouldBeLessThanOrEqual(0)\n            } else {\n                effect.amplifier.shouldBeExactly(expectedAmp)\n            }\n        }\n\n        val baseSnapshot = snapshotBase(meta)\n        baseSnapshot.baseType.shouldBe(PotionType.WATER)\n        baseSnapshot.isUpgraded.shouldBeFalse()\n        baseSnapshot.isExtended.shouldBeFalse()\n    }\n\n    fun assertUnchanged(item: ItemStack, originalBase: PotionBaseSnapshot) {\n        val meta = item.itemMeta as PotionMeta\n        meta.customEffects.shouldHaveSize(0)\n        val newBase = snapshotBase(meta)\n        newBase.baseType.shouldBe(originalBase.baseType)\n        newBase.isUpgraded.shouldBe(originalBase.isUpgraded)\n        newBase.isExtended.shouldBe(originalBase.isExtended)\n    }\n\n\n    fun callConsume(item: ItemStack): ItemStack {\n        val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 3 &&\n                Player::class.java.isAssignableFrom(params[0]) &&\n                ItemStack::class.java.isAssignableFrom(params[1]) &&\n                EquipmentSlot::class.java.isAssignableFrom(params[2])\n        }\n        val event = if (ctor != null) {\n            ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent\n        } else {\n            PlayerItemConsumeEvent(player, item)\n        }\n        Bukkit.getPluginManager().callEvent(event)\n        return event.item\n    }\n\n    fun callThrow(item: ItemStack): ItemStack {\n        val block = player.location.block\n        val face = BlockFace.SELF\n        val ctor = PlayerInteractEvent::class.java.constructors.firstOrNull { constructor ->\n            val params = constructor.parameterTypes\n            params.size == 6 &&\n                Player::class.java.isAssignableFrom(params[0]) &&\n                params[1] == Action::class.java &&\n                ItemStack::class.java.isAssignableFrom(params[2]) &&\n                params[3].name.endsWith(\"Block\") &&\n                params[4].name.endsWith(\"BlockFace\") &&\n                params[5] == EquipmentSlot::class.java\n        }\n        val event = if (ctor != null) {\n            ctor.newInstance(player, Action.RIGHT_CLICK_AIR, item, block, face, EquipmentSlot.HAND) as PlayerInteractEvent\n        } else {\n            PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, item, block, face)\n        }\n        Bukkit.getPluginManager().callEvent(event)\n        return event.item ?: item\n    }\n\n    fun callDispense(item: ItemStack): ItemStack {\n        val block = player.world.getBlockAt(player.location.blockX, player.location.blockY, player.location.blockZ)\n        val event = BlockDispenseEvent(block, item, Vector(0, 0, 0))\n        Bukkit.getPluginManager().callEvent(event)\n        return event.item\n    }\n\n    fun assertAdjustedOrUnchanged(\n        adjusted: ItemStack,\n        potionCase: PotionCase,\n        originalBase: PotionBaseSnapshot,\n        splash: Boolean\n    ) {\n        val expectedTicks = if (splash) potionCase.splashTicks else potionCase.drinkableTicks\n        if (excludedPotionTypes.contains(debugName(potionCase.baseName, potionCase.isStrong, potionCase.isExtended))) {\n            assertUnchanged(adjusted, originalBase)\n        } else {\n            assertAdjusted(adjusted, potionCase.baseName, potionCase.isStrong, potionCase.potion, expectedTicks)\n        }\n    }\n\n    fun findSamplePotionCase(): PotionCase {\n        return loadPotionCases().firstOrNull()\n            ?: error(\"No configured potions available for this server version.\")\n    }\n\n    suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n        val enabled = ocm.config.getBoolean(\"old-potion-effects.enabled\")\n        val strengthSection = ocm.config.getConfigurationSection(\"old-potion-effects.strength\")\n        val weaknessSection = ocm.config.getConfigurationSection(\"old-potion-effects.weakness\")\n        val drinkSection = ocm.config.getConfigurationSection(\"old-potion-effects.potion-durations.drinkable\")\n        val splashSection = ocm.config.getConfigurationSection(\"old-potion-effects.potion-durations.splash\")\n        val alwaysEnabledModules = ocm.config.getStringList(\"always_enabled_modules\")\n        val disabledModules = ocm.config.getStringList(\"disabled_modules\")\n        val modesetSection = ocm.config.getConfigurationSection(\"modesets\")\n\n        val strengthSnapshot = strengthSection?.getValues(false) ?: emptyMap<String, Any>()\n        val weaknessSnapshot = weaknessSection?.getValues(false) ?: emptyMap<String, Any>()\n        val drinkSnapshot = drinkSection?.getKeys(false)?.associateWith { drinkSection.get(it) } ?: emptyMap()\n        val splashSnapshot = splashSection?.getKeys(false)?.associateWith { splashSection.get(it) } ?: emptyMap()\n        val modesetSnapshot = modesetSection?.getKeys(false)?.associateWith { key ->\n            ocm.config.getStringList(\"modesets.$key\")\n        } ?: emptyMap()\n\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"old-potion-effects.enabled\", enabled)\n            strengthSnapshot.forEach { (key, value) ->\n                ocm.config.set(\"old-potion-effects.strength.$key\", value)\n            }\n            weaknessSnapshot.forEach { (key, value) ->\n                ocm.config.set(\"old-potion-effects.weakness.$key\", value)\n            }\n            drinkSnapshot.forEach { (key, value) ->\n                ocm.config.set(\"old-potion-effects.potion-durations.drinkable.$key\", value)\n            }\n            splashSnapshot.forEach { (key, value) ->\n                ocm.config.set(\"old-potion-effects.potion-durations.splash.$key\", value)\n            }\n            ocm.config.set(\"always_enabled_modules\", alwaysEnabledModules)\n            ocm.config.set(\"disabled_modules\", disabledModules)\n            modesetSnapshot.forEach { (key, list) ->\n                ocm.config.set(\"modesets.$key\", list)\n            }\n            modesetSection?.getKeys(false)\n                ?.filterNot { modesetSnapshot.containsKey(it) }\n                ?.forEach { key -> ocm.config.set(\"modesets.$key\", null) }\n            ocm.saveConfig()\n            Config.reload()\n        }\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun <T> runSyncResult(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()\n        }\n    }\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val location = Location(world, 0.0, 100.0, 0.0)\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n            player.isOp = true\n            val playerData = getPlayerData(player.uniqueId)\n            playerData.setModesetForWorld(player.world.uid, \"old\")\n            setPlayerData(player.uniqueId, playerData)\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakePlayer.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            player.inventory.clear()\n            player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n            val playerData = getPlayerData(player.uniqueId)\n            playerData.setModesetForWorld(player.world.uid, \"old\")\n            setPlayerData(player.uniqueId, playerData)\n        }\n    }\n\n    context(\"Drinkable potions\") {\n        test(\"configured drinkable potions are adjusted when duration is loaded\") {\n            val cases = loadPotionCases()\n            cases.isNotEmpty().shouldBeTrue()\n            cases.forEach { potionCase ->\n                val item = createPotionItem(Material.POTION, potionCase)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = snapshotBase(meta)\n                val adjusted = callConsume(item)\n                assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = false)\n            }\n        }\n    }\n\n    context(\"Weakness neutralisation\") {\n        test(\"weakness potion does not reduce attack damage\") {\n            val weaknessCase = loadPotionCases().firstOrNull { it.baseName == \"WEAKNESS\" }\n                ?: return@test\n            val item = createPotionItem(Material.POTION, weaknessCase)\n            val adjusted = callConsume(item)\n            val meta = adjusted.itemMeta as PotionMeta\n            val effect = meta.customEffects.firstOrNull { it.type == PotionEffectType.WEAKNESS }\n                ?: error(\"Weakness effect missing from potion meta\")\n\n            val attackAttribute = XAttribute.ATTACK_DAMAGE.get()\n                ?: error(\"Attack damage attribute not available\")\n            var baseDamage = 0.0\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.AIR))\n                player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                baseDamage = player.getAttribute(attackAttribute)?.value\n                    ?: error(\"Attack damage attribute missing on player\")\n                player.addPotionEffect(effect, true)\n            }\n            delay(50)\n            var afterDamage = 0.0\n            runSync {\n                afterDamage = player.getAttribute(attackAttribute)?.value\n                    ?: error(\"Attack damage attribute missing on player\")\n            }\n            afterDamage.shouldBe(baseDamage.plusOrMinus(0.0001))\n        }\n\n        test(\"direct weakness effect does not reduce attack damage\") {\n            val attackAttribute = XAttribute.ATTACK_DAMAGE.get()\n                ?: error(\"Attack damage attribute not available\")\n            var baseDamage = 0.0\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.AIR))\n                player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                baseDamage = player.getAttribute(attackAttribute)?.value\n                    ?: error(\"Attack damage attribute missing on player\")\n                player.addPotionEffect(PotionEffect(PotionEffectType.WEAKNESS, 200, -1), true)\n            }\n            delay(50)\n            var afterDamage = 0.0\n            runSync {\n                afterDamage = player.getAttribute(attackAttribute)?.value\n                    ?: error(\"Attack damage attribute missing on player\")\n            }\n            afterDamage.shouldBe(baseDamage.plusOrMinus(0.0001))\n        }\n    }\n\n    context(\"Weakness damage event diagnostic\") {\n        test(\"vanilla damage event for weakness + low damage + no-damage window\") {\n            lateinit var attacker: Player\n            lateinit var victim: LivingEntity\n            var attackerFake: FakePlayer? = null\n            val events = mutableListOf<EntityDamageByEntityEvent>()\n\n            val listener = object : Listener {\n                @EventHandler\n                fun onDamage(event: EntityDamageByEntityEvent) {\n                    if (event.entity.uniqueId == victim.uniqueId &&\n                        event.damager.uniqueId == attacker.uniqueId\n                    ) {\n                        events.add(event)\n                    }\n                }\n            }\n\n            try {\n                runSync {\n                    val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                    val attackerLocation = Location(world, 0.0, 100.0, 0.0).apply { yaw = 0f; pitch = 0f }\n                    val victimLocation = Location(world, 1.2, 100.0, 0.0)\n\n                    attackerFake = FakePlayer(testPlugin)\n                    attackerFake!!.spawn(attackerLocation)\n                    attacker = checkNotNull(Bukkit.getPlayer(attackerFake!!.uuid))\n                    attacker.isOp = true\n                    attacker.inventory.clear()\n                    attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }\n                    attacker.gameMode = GameMode.SURVIVAL\n\n                    val attackerData = getPlayerData(attacker.uniqueId)\n                    attackerData.setModesetForWorld(attacker.world.uid, \"old\")\n                    setPlayerData(attacker.uniqueId, attackerData)\n\n                    victim = world.spawn(victimLocation, org.bukkit.entity.Cow::class.java)\n                    victim.maximumNoDamageTicks = 20\n                    victim.noDamageTicks = 0\n                    victim.isInvulnerable = false\n                    victim.health = victim.maxHealth\n\n                    Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                }\n                delay(200)\n\n                fun attackDamage(): Double {\n                    val attribute = XAttribute.ATTACK_DAMAGE.get()\n                        ?: error(\"Attack damage attribute not available\")\n                    return attacker.getAttribute(attribute)?.value ?: 0.0\n                }\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\") // Deprecated constructor kept for older server compatibility in tests.\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = attacker.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun record(label: String, expectedDamage: Double, action: () -> Unit): Boolean {\n                    val before = events.size\n                    runSync {\n                        Bukkit.getScheduler().runTask(testPlugin, Runnable { action() })\n                    }\n                    delay(150)\n                    val fired = events.size > before\n                    testPlugin.logger.info(\n                        \"Weakness diagnostic [$label] fired=$fired \" +\n                            \"noDamageTicks=${victim.noDamageTicks} lastDamage=${victim.lastDamage} \" +\n                            \"eventType=${events.lastOrNull()?.javaClass?.simpleName} \" +\n                            \"cause=${events.lastOrNull()?.cause} \" +\n                            \"eventDamage=${events.lastOrNull()?.damage} \" +\n                            \"finalDamage=${events.lastOrNull()?.finalDamage} \" +\n                            \"damagerType=${events.lastOrNull()?.damager?.javaClass?.simpleName} \" +\n                            \"damagerId=${events.lastOrNull()?.damager?.uniqueId} \" +\n                            \"inputDamage=$expectedDamage\"\n                    )\n                    return fired\n                }\n\n                runSync {\n                    val weapon = ItemStack(Material.DIAMOND_SWORD)\n                    prepareWeapon(weapon)\n                    attacker.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    attacker.updateInventory()\n                    attacker.isInvulnerable = false\n                    attacker.health = attacker.maxHealth\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                }\n                delay(100)\n                val baselineDamage = attackDamage()\n                waitForAttackReady(attacker)\n                record(\"baseline\", baselineDamage) {\n                    attackCompat(attacker, victim)\n                }\n\n                runSync {\n                    attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }\n                    attacker.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 200, 0))\n                    val lowItem = ItemStack(Material.STONE_SWORD)\n                    prepareWeapon(lowItem)\n                    attacker.inventory.setItemInMainHand(lowItem)\n                    applyAttackDamageModifiers(lowItem)\n                    attacker.updateInventory()\n                    attacker.isInvulnerable = false\n                    attacker.health = attacker.maxHealth\n                    victim.noDamageTicks = 0\n                    victim.lastDamage = 0.0\n                }\n                delay(100)\n                val weakDamage = attackDamage()\n                waitForAttackReady(attacker)\n                record(\"weakness-no-invuln\", weakDamage) {\n                    attackCompat(attacker, victim)\n                }\n\n                runSync {\n                    victim.noDamageTicks = victim.maximumNoDamageTicks\n                    victim.lastDamage = 20.0\n                }\n                delay(100)\n                waitForAttackReady(attacker)\n                record(\"weakness-invuln\", weakDamage) {\n                    attackCompat(attacker, victim)\n                }\n            } finally {\n                HandlerList.unregisterAll(listener)\n                runSync {\n                    attackerFake?.removePlayer()\n                    victim.remove()\n                }\n            }\n        }\n    }\n\n    context(\"Splash potions\") {\n        test(\"player throws splash potions with module durations\") {\n            val cases = loadPotionCases()\n            cases.forEach { potionCase ->\n                val item = createPotionItem(Material.SPLASH_POTION, potionCase)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = snapshotBase(meta)\n                val adjusted = callThrow(item)\n                assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = true)\n            }\n        }\n\n        test(\"dispenser does not mutate splash potions without setItem\") {\n            val cases = loadPotionCases()\n            cases.forEach { potionCase ->\n                val item = createPotionItem(Material.SPLASH_POTION, potionCase)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = snapshotBase(meta)\n                val adjusted = callDispense(item)\n                assertUnchanged(adjusted, originalBase)\n            }\n        }\n    }\n\n    context(\"Lingering potions\") {\n        test(\"player throws lingering potions with module splash durations\") {\n            val cases = loadPotionCases()\n            cases.forEach { potionCase ->\n                val item = createPotionItem(Material.LINGERING_POTION, potionCase)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = snapshotBase(meta)\n                val adjusted = callThrow(item)\n                assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = true)\n            }\n        }\n\n        test(\"dispenser does not mutate lingering potions without setItem\") {\n            val cases = loadPotionCases()\n            cases.forEach { potionCase ->\n                val item = createPotionItem(Material.LINGERING_POTION, potionCase)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = snapshotBase(meta)\n                val adjusted = callDispense(item)\n                assertUnchanged(adjusted, originalBase)\n            }\n        }\n    }\n\n    context(\"Excluded potions\") {\n        test(\"excluded potion types are not modified\") {\n            excludedPotionTypes.forEach { name ->\n                val potionType = runCatching { PotionType.valueOf(name) }.getOrNull() ?: return@forEach\n                val item = ItemStack(Material.POTION)\n                val meta = item.itemMeta as PotionMeta\n                val originalBase = runCatching {\n                    try {\n                        meta.basePotionType = potionType\n                    } catch (e: NoSuchMethodError) {\n                        meta.basePotionData = PotionData(potionType, false, false)\n                    }\n                    snapshotBase(meta)\n                }.getOrElse { return@forEach }\n                item.itemMeta = meta\n\n                val adjusted = callConsume(item)\n                assertUnchanged(adjusted, originalBase)\n            }\n        }\n    }\n\n    context(\"Missing config\") {\n        test(\"missing potion entry leaves potion unchanged\") {\n            withConfig {\n                val potionCase = findSamplePotionCase()\n                ocm.config.set(\"old-potion-effects.potion-durations.drinkable.${potionCase.key}\", null)\n                ocm.config.set(\"old-potion-effects.potion-durations.splash.${potionCase.key}\", null)\n                module.reload()\n\n                val item = createPotionItem(Material.POTION, potionCase)\n                val originalBase = snapshotBase(item.itemMeta as PotionMeta)\n                val adjusted = callConsume(item)\n                assertUnchanged(adjusted, originalBase)\n            }\n        }\n    }\n\n    context(\"Module disabled\") {\n        test(\"disabled via modeset leaves potions unchanged\") {\n            val potionCase = findSamplePotionCase()\n            val playerData = getPlayerData(player.uniqueId)\n            playerData.setModesetForWorld(player.world.uid, \"new\")\n            setPlayerData(player.uniqueId, playerData)\n            val item = createPotionItem(Material.POTION, potionCase)\n            val originalBase = snapshotBase(item.itemMeta as PotionMeta)\n            val adjusted = callConsume(item)\n            assertUnchanged(adjusted, originalBase)\n        }\n\n        test(\"disabled via config leaves potions unchanged\") {\n            withConfig {\n                val potionCase = findSamplePotionCase()\n                val disabled = ocm.config.getStringList(\"disabled_modules\")\n                    .filterNot { it.equals(\"old-potion-effects\", true) }\n                    .toMutableList()\n                disabled.add(\"old-potion-effects\")\n                ocm.config.set(\"disabled_modules\", disabled)\n                ocm.config.set(\n                    \"always_enabled_modules\",\n                    ocm.config.getStringList(\"always_enabled_modules\")\n                        .filterNot { it.equals(\"old-potion-effects\", true) }\n                )\n                val modesetsSection = ocm.config.getConfigurationSection(\"modesets\")\n                    ?: error(\"Missing 'modesets' section in config\")\n                modesetsSection.getKeys(false).forEach { key ->\n                    val modules = ocm.config.getStringList(\"modesets.$key\")\n                        .filterNot { it.equals(\"old-potion-effects\", true) }\n                    ocm.config.set(\"modesets.$key\", modules)\n                }\n                ocm.saveConfig()\n                Config.reload()\n\n                val item = createPotionItem(Material.POTION, potionCase)\n                val originalBase = snapshotBase(item.itemMeta as PotionMeta)\n                val adjusted = callConsume(item)\n                assertUnchanged(adjusted, originalBase)\n            }\n        }\n    }\n\n    context(\"Strength and weakness modifiers\") {\n        test(\"vanilla strength addend applies when old-potion-effects is disabled\") {\n            withConfig {\n                val disabled = ocm.config.getStringList(\"disabled_modules\")\n                    .filterNot { it.equals(\"old-potion-effects\", true) }\n                    .toMutableList()\n                disabled.add(\"old-potion-effects\")\n                ocm.config.set(\"disabled_modules\", disabled)\n                ocm.config.set(\n                    \"always_enabled_modules\",\n                    ocm.config.getStringList(\"always_enabled_modules\")\n                        .filterNot { it.equals(\"old-potion-effects\", true) }\n                )\n                val modesetsSection = ocm.config.getConfigurationSection(\"modesets\")\n                    ?: error(\"Missing 'modesets' section in config\")\n                modesetsSection.getKeys(false).forEach { key ->\n                    val modules = ocm.config.getStringList(\"modesets.$key\")\n                        .filterNot { it.equals(\"old-potion-effects\", true) }\n                    ocm.config.set(\"modesets.$key\", modules)\n                }\n                ocm.saveConfig()\n                Config.reload()\n\n                val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\")\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun captureDamage(victim: LivingEntity): Double {\n                    val events = mutableListOf<EntityDamageByEntityEvent>()\n                    val listener = object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                        fun onDamage(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId == player.uniqueId &&\n                                event.entity.uniqueId == victim.uniqueId\n                            ) {\n                                events.add(event)\n                            }\n                        }\n                    }\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                    }\n                    try {\n                        waitForAttackReady(player)\n                        runSync {\n                            attackCompat(player, victim)\n                        }\n                        delay(200)\n                    } finally {\n                        HandlerList.unregisterAll(listener)\n                    }\n                    val event = events.lastOrNull()\n                        ?: error(\"Expected a damage event for vanilla strength test\")\n                    return event.damage\n                }\n\n                prepareWeapon(weapon)\n                runSync {\n                    player.inventory.clear()\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    player.updateInventory()\n                    player.isSprinting = false\n                    player.fallDistance = 0f\n                    player.velocity = Vector(0.0, 0.0, 0.0)\n                    player.isInvulnerable = false\n                    player.health = player.maxHealth\n                }\n\n                val baselineVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                val baselineDamage = captureDamage(baselineVictim)\n                runSync { baselineVictim.remove() }\n\n                val strengthVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)\n                }\n                delay(50)\n                val strengthDamage = captureDamage(strengthVictim)\n                runSync { strengthVictim.remove() }\n\n                val delta = strengthDamage - baselineDamage\n                delta.shouldBe(6.0.plusOrMinus(0.0001))\n            }\n        }\n\n        test(\"damage modifiers are applied from config\") {\n            withConfig {\n                val strengthModifier = 2.4\n                val weaknessModifier = -0.75\n                ocm.config.set(\"old-potion-effects.strength.modifier\", strengthModifier)\n                ocm.config.set(\"old-potion-effects.strength.multiplier\", false)\n                ocm.config.set(\"old-potion-effects.strength.addend\", true)\n                ocm.config.set(\"old-potion-effects.weakness.modifier\", weaknessModifier)\n                ocm.config.set(\"old-potion-effects.weakness.multiplier\", true)\n                module.reload()\n\n                player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 0))\n                player.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 200, 0))\n                val defender = player\n\n                val event = OCMEntityDamageByEntityEvent(\n                    player,\n                    defender,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    4.0\n                )\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.strengthModifier.shouldBe(strengthModifier)\n                event.isStrengthModifierMultiplier.shouldBeFalse()\n                event.isStrengthModifierAddend.shouldBeTrue()\n                event.weaknessModifier.shouldBe(weaknessModifier)\n                event.isWeaknessModifierMultiplier.shouldBeTrue()\n                event.weaknessLevel.shouldBe(1)\n            }\n        }\n\n        test(\"strength addend scales per level for Strength II\") {\n            withConfig {\n                val strengthModifier = 2.0\n                ocm.config.set(\"old-potion-effects.strength.modifier\", strengthModifier)\n                ocm.config.set(\"old-potion-effects.strength.multiplier\", false)\n                ocm.config.set(\"old-potion-effects.strength.addend\", true)\n                module.reload()\n\n                val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\")\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun captureDamage(victim: LivingEntity): Double {\n                    val events = mutableListOf<EntityDamageByEntityEvent>()\n                    val listener = object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                        fun onDamage(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId == player.uniqueId &&\n                                event.entity.uniqueId == victim.uniqueId\n                            ) {\n                                events.add(event)\n                            }\n                        }\n                    }\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                    }\n                    try {\n                        waitForAttackReady(player)\n                        runSync {\n                            attackCompat(player, victim)\n                        }\n                        delay(200)\n                    } finally {\n                        HandlerList.unregisterAll(listener)\n                    }\n                    val event = events.lastOrNull()\n                        ?: error(\"Expected a damage event for strength addend test\")\n                    return event.damage\n                }\n\n                prepareWeapon(weapon)\n                runSync {\n                    player.inventory.clear()\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    player.updateInventory()\n                    player.isSprinting = false\n                    player.fallDistance = 0f\n                    player.velocity = Vector(0.0, 0.0, 0.0)\n                    player.isInvulnerable = false\n                    player.health = player.maxHealth\n                }\n\n                val baselineVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                val baselineDamage = captureDamage(baselineVictim)\n                runSync { baselineVictim.remove() }\n\n                val strengthVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)\n                }\n                delay(50)\n                val strengthDamage = captureDamage(strengthVictim)\n                runSync { strengthVictim.remove() }\n\n                val delta = strengthDamage - baselineDamage\n                delta.shouldBe((strengthModifier * 2).plusOrMinus(0.0001))\n            }\n        }\n\n        test(\"strength addend scales per level for Strength III\") {\n            withConfig {\n                val strengthModifier = 2.0\n                ocm.config.set(\"old-potion-effects.strength.modifier\", strengthModifier)\n                ocm.config.set(\"old-potion-effects.strength.multiplier\", false)\n                ocm.config.set(\"old-potion-effects.strength.addend\", true)\n                module.reload()\n\n                val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\")\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun captureDamage(victim: LivingEntity): Double {\n                    val events = mutableListOf<EntityDamageByEntityEvent>()\n                    val listener = object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                        fun onDamage(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId == player.uniqueId &&\n                                event.entity.uniqueId == victim.uniqueId\n                            ) {\n                                events.add(event)\n                            }\n                        }\n                    }\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                    }\n                    try {\n                        waitForAttackReady(player)\n                        runSync {\n                            attackCompat(player, victim)\n                        }\n                        delay(200)\n                    } finally {\n                        HandlerList.unregisterAll(listener)\n                    }\n                    val event = events.lastOrNull()\n                        ?: error(\"Expected a damage event for strength addend test\")\n                    return event.damage\n                }\n\n                prepareWeapon(weapon)\n                runSync {\n                    player.inventory.clear()\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    player.updateInventory()\n                    player.isSprinting = false\n                    player.fallDistance = 0f\n                    player.velocity = Vector(0.0, 0.0, 0.0)\n                    player.isInvulnerable = false\n                    player.health = player.maxHealth\n                }\n\n                val baselineVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                val baselineDamage = captureDamage(baselineVictim)\n                runSync { baselineVictim.remove() }\n\n                val strengthVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 2), true)\n                }\n                delay(50)\n                val strengthDamage = captureDamage(strengthVictim)\n                runSync { strengthVictim.remove() }\n\n                val delta = strengthDamage - baselineDamage\n                delta.shouldBe((strengthModifier * 3).plusOrMinus(0.0001))\n            }\n        }\n\n        test(\"strength addend respects configured modifier value\") {\n            withConfig {\n                val strengthModifier = 4.5\n                ocm.config.set(\"old-potion-effects.strength.modifier\", strengthModifier)\n                ocm.config.set(\"old-potion-effects.strength.multiplier\", false)\n                ocm.config.set(\"old-potion-effects.strength.addend\", true)\n                module.reload()\n\n                val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\")\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun captureDamage(victim: LivingEntity): Double {\n                    val events = mutableListOf<EntityDamageByEntityEvent>()\n                    val listener = object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                        fun onDamage(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId == player.uniqueId &&\n                                event.entity.uniqueId == victim.uniqueId\n                            ) {\n                                events.add(event)\n                            }\n                        }\n                    }\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                    }\n                    try {\n                        waitForAttackReady(player)\n                        runSync {\n                            attackCompat(player, victim)\n                        }\n                        delay(200)\n                    } finally {\n                        HandlerList.unregisterAll(listener)\n                    }\n                    val event = events.lastOrNull()\n                        ?: error(\"Expected a damage event for strength addend test\")\n                    return event.damage\n                }\n\n                prepareWeapon(weapon)\n                runSync {\n                    player.inventory.clear()\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    player.updateInventory()\n                    player.isSprinting = false\n                    player.fallDistance = 0f\n                    player.velocity = Vector(0.0, 0.0, 0.0)\n                    player.isInvulnerable = false\n                    player.health = player.maxHealth\n                }\n\n                val baselineVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                val baselineDamage = captureDamage(baselineVictim)\n                runSync { baselineVictim.remove() }\n\n                val strengthVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 0), true)\n                }\n                delay(50)\n                val strengthDamage = captureDamage(strengthVictim)\n                runSync { strengthVictim.remove() }\n\n                val delta = strengthDamage - baselineDamage\n                delta.shouldBe(strengthModifier.plusOrMinus(0.0001))\n            }\n        }\n\n        test(\"strength multiplier scales base damage\") {\n            withConfig {\n                val strengthModifier = 1.4\n                ocm.config.set(\"old-potion-effects.strength.modifier\", strengthModifier)\n                ocm.config.set(\"old-potion-effects.strength.multiplier\", true)\n                ocm.config.set(\"old-potion-effects.strength.addend\", false)\n                module.reload()\n\n                val world = checkNotNull(Bukkit.getServer().getWorld(\"world\"))\n                val weapon = ItemStack(Material.DIAMOND_SWORD)\n\n                fun prepareWeapon(item: ItemStack) {\n                    val meta = item.itemMeta ?: return\n                    @Suppress(\"DEPRECATION\")\n                    val speedModifier = createAttributeModifier(\n                        name = \"speed\",\n                        amount = 1000.0,\n                        operation = AttributeModifier.Operation.ADD_NUMBER,\n                        slot = EquipmentSlot.HAND\n                    )\n                    val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return\n                    addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)\n                    item.itemMeta = meta\n                }\n\n                fun applyAttackDamageModifiers(item: ItemStack) {\n                    val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return\n                    val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return\n                    val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)\n                    modifiers.forEach { modifier ->\n                        attackAttribute.removeModifier(modifier)\n                        attackAttribute.addModifier(modifier)\n                    }\n                }\n\n                suspend fun captureDamage(victim: LivingEntity): Double {\n                    val events = mutableListOf<EntityDamageByEntityEvent>()\n                    val listener = object : Listener {\n                        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                        fun onDamage(event: EntityDamageByEntityEvent) {\n                            if (event.damager.uniqueId == player.uniqueId &&\n                                event.entity.uniqueId == victim.uniqueId\n                            ) {\n                                events.add(event)\n                            }\n                        }\n                    }\n                    runSync {\n                        Bukkit.getPluginManager().registerEvents(listener, testPlugin)\n                    }\n                    try {\n                        waitForAttackReady(player)\n                        runSync {\n                            attackCompat(player, victim)\n                        }\n                        delay(200)\n                    } finally {\n                        HandlerList.unregisterAll(listener)\n                    }\n                    val event = events.lastOrNull()\n                        ?: error(\"Expected a damage event for strength multiplier test\")\n                    return event.damage\n                }\n\n                prepareWeapon(weapon)\n                runSync {\n                    player.inventory.clear()\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.inventory.setItemInMainHand(weapon)\n                    applyAttackDamageModifiers(weapon)\n                    player.updateInventory()\n                    player.isSprinting = false\n                    player.fallDistance = 0f\n                    player.velocity = Vector(0.0, 0.0, 0.0)\n                    player.isInvulnerable = false\n                    player.health = player.maxHealth\n                }\n\n                val baselineVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                val baselineDamage = captureDamage(baselineVictim)\n                runSync { baselineVictim.remove() }\n\n                val strengthVictim = runSyncResult {\n                    world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {\n                        maximumNoDamageTicks = 20\n                        noDamageTicks = 0\n                        isInvulnerable = false\n                        health = maxHealth\n                    }\n                }\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)\n                }\n                delay(50)\n                val strengthDamage = captureDamage(strengthVictim)\n                runSync { strengthVictim.remove() }\n\n                val ratio = strengthDamage / baselineDamage\n                ratio.shouldBe((strengthModifier * 2).plusOrMinus(0.0001))\n            }\n        }\n\n        test(\"weakness II is capped to level one for old modifier logic\") {\n            withConfig {\n                val weaknessModifier = -0.5\n                ocm.config.set(\"old-potion-effects.weakness.modifier\", weaknessModifier)\n                ocm.config.set(\"old-potion-effects.weakness.multiplier\", false)\n                module.reload()\n\n                val weakness = XPotion.WEAKNESS.get() ?: error(\"Weakness potion missing\")\n                runSync {\n                    player.addPotionEffect(PotionEffect(weakness, 200, 1), true)\n                }\n\n                val event = OCMEntityDamageByEntityEvent(\n                    player,\n                    player,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    4.0\n                )\n                Bukkit.getPluginManager().callEvent(event)\n\n                event.hasWeakness().shouldBeTrue()\n                event.weaknessModifier.shouldBe(weaknessModifier)\n                event.isWeaknessModifierMultiplier.shouldBeFalse()\n                event.weaknessLevel.shouldBe(1)\n            }\n        }\n\n        test(\"high amplifier weakness does not distort base damage reconstruction\") {\n            withConfig {\n                val weakness = XPotion.WEAKNESS.get() ?: error(\"Weakness potion missing\")\n                var baseLevel0 = 0.0\n                var baseLevel67 = 0.0\n\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(weakness, 200, 0), true)\n                }\n\n                val eventLevel0 = OCMEntityDamageByEntityEvent(\n                    player,\n                    player,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    4.0\n                )\n                Bukkit.getPluginManager().callEvent(eventLevel0)\n                baseLevel0 = eventLevel0.baseDamage\n\n                runSync {\n                    player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                    player.addPotionEffect(PotionEffect(weakness, 200, 67), true)\n                }\n\n                val eventLevel67 = OCMEntityDamageByEntityEvent(\n                    player,\n                    player,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    4.0\n                )\n                Bukkit.getPluginManager().callEvent(eventLevel67)\n                baseLevel67 = eventLevel67.baseDamage\n\n                baseLevel67.shouldBe(baseLevel0.plusOrMinus(0.0001))\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldToolDamageMobIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Villager\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.lang.reflect.Method\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass OldToolDamageMobIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun runSync(action: () -> Unit) {\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(\n                        testPlugin,\n                        Callable {\n                            action()\n                            null\n                        },\n                    ).get()\n            }\n        }\n\n        suspend fun delayTicks(ticks: Long) {\n            delay(ticks * 50L)\n        }\n\n        suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n            val damagesSection = ocm.config.getConfigurationSection(\"old-tool-damage.damages\")\n            val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()\n            val disabledModules = ocm.config.getStringList(\"disabled_modules\")\n            val modesetsSection =\n                ocm.config.getConfigurationSection(\"modesets\")\n                    ?: error(\"Missing 'modesets' section in config\")\n            val modesetSnapshot =\n                modesetsSection.getKeys(false).associateWith { key ->\n                    ocm.config.getStringList(\"modesets.$key\")\n                }\n\n            fun reloadAll() {\n                ocm.saveConfig()\n                Config.reload()\n            }\n\n            try {\n                block()\n            } finally {\n                damagesSnapshot.forEach { (key, value) ->\n                    ocm.config.set(\"old-tool-damage.damages.$key\", value)\n                }\n                ocm.config.set(\"disabled_modules\", disabledModules)\n                modesetSnapshot.forEach { (key, list) ->\n                    ocm.config.set(\"modesets.$key\", list)\n                }\n                reloadAll()\n            }\n        }\n\n        fun mobMethodSignature(method: Method): String {\n            val params = method.parameterTypes.joinToString(\",\") { it.name }\n            return \"${method.declaringClass.name}#${method.name}($params):${method.returnType.name}\"\n        }\n\n        fun mobCollectAllMethods(start: Class<*>): List<Method> {\n            val methods = LinkedHashMap<String, Method>()\n            var current: Class<*>? = start\n            while (current != null) {\n                current.declaredMethods.forEach { method ->\n                    methods.putIfAbsent(mobMethodSignature(method), method)\n                }\n                current = current.superclass\n            }\n            start.methods.forEach { method ->\n                methods.putIfAbsent(mobMethodSignature(method), method)\n            }\n            return methods.values.toList()\n        }\n\n        fun mobScoreAttackMethod(method: Method): Int {\n            var score = 0\n            if (method.name == \"attack\") score += 100\n            if (method.name == \"a\") score += 80\n            val param = method.parameterTypes[0]\n            if (param.simpleName == \"Entity\") score += 40\n            if (param.simpleName.contains(\"Entity\")) score += 10\n            if (method.returnType == Void.TYPE) score += 10\n            if (method.returnType == java.lang.Boolean.TYPE) score += 8\n            val declaring = method.declaringClass.simpleName\n            if (declaring.contains(\"EntityInsentient\")) score += 20\n            if (declaring.contains(\"Mob\")) score += 10\n            return score\n        }\n\n        fun resolveDebugFile(): java.io.File {\n            val versionTag = Bukkit.getBukkitVersion().replace(Regex(\"[^A-Za-z0-9_.-]\"), \"_\")\n            val runDir = java.io.File(System.getProperty(\"user.dir\"))\n            val repoRoot = runDir.parentFile?.parentFile ?: runDir\n            return java.io.File(repoRoot, \"build/mob-tool-damage-debug-$versionTag.txt\")\n        }\n\n        fun attackCompat(\n            attacker: LivingEntity,\n            target: Entity,\n        ): Boolean {\n            val handleMethod =\n                attacker.javaClass.methods.firstOrNull { method ->\n                    method.name == \"getHandle\" && method.parameterTypes.isEmpty()\n                } ?: error(\"Failed to resolve CraftEntity#getHandle for ${attacker.javaClass.name}\")\n\n            val attackerHandle =\n                handleMethod.invoke(attacker)\n                    ?: error(\"CraftEntity#getHandle returned null for ${attacker.javaClass.name}\")\n\n            val targetHandle =\n                target.javaClass.methods\n                    .firstOrNull { method ->\n                        method.name == \"getHandle\" && method.parameterTypes.isEmpty()\n                    }?.invoke(target) ?: error(\"Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}\")\n\n            val candidates =\n                listOfNotNull(\n                    Reflector.getMethodAssignable(attackerHandle.javaClass, \"attack\", targetHandle.javaClass),\n                    Reflector.getMethodAssignable(attackerHandle.javaClass, \"a\", targetHandle.javaClass),\n                ).ifEmpty {\n                    mobCollectAllMethods(attackerHandle.javaClass)\n                        .asSequence()\n                        .filter { it.parameterCount == 1 }\n                        .filter { it.parameterTypes[0].isAssignableFrom(targetHandle.javaClass) }\n                        .filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }\n                        .sortedByDescending { mobScoreAttackMethod(it) }\n                        .toList()\n                }\n\n            candidates.forEach { it.isAccessible = true }\n            for (method in candidates) {\n                try {\n                    val result = method.invoke(attackerHandle, targetHandle)\n                    if (result is Boolean && !result) {\n                        continue\n                    }\n                    return true\n                } catch (ignored: Exception) {\n                    // try next\n                }\n            }\n\n            return false\n        }\n\n        suspend fun captureVindicatorBaseDamage(\n            debugFile: java.io.File,\n            label: String,\n        ): Double {\n            val mobClass: Class<out LivingEntity> =\n                try {\n                    @Suppress(\"UNCHECKED_CAST\")\n                    Class.forName(\"org.bukkit.entity.Vindicator\") as Class<out LivingEntity>\n                } catch (_: ClassNotFoundException) {\n                    org.bukkit.entity.Zombie::class.java\n                }\n\n            lateinit var victim: Villager\n            lateinit var mob: LivingEntity\n\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val victimLocation = Location(world, 0.0, 100.0, 0.0)\n                val mobLocation = Location(world, 1.1, 100.0, 0.0)\n\n                victim = world.spawn(victimLocation, Villager::class.java)\n                victim.isInvulnerable = false\n                victim.health = victim.maxHealth\n                victim.maximumNoDamageTicks = 0\n                victim.noDamageTicks = 0\n\n                mob = world.spawn(mobLocation, mobClass)\n                mob.isSilent = true\n                mob.equipment?.setItemInMainHand(ItemStack(Material.IRON_AXE))\n                mob.maximumNoDamageTicks = 0\n                mob.noDamageTicks = 0\n            }\n\n            try {\n                val baseDamage = NewWeaponDamage.getDamage(Material.IRON_AXE) // vanilla 1.9 base\n                val event =\n                    EntityDamageByEntityEvent(\n                        mob,\n                        victim,\n                        org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                        baseDamage.toDouble(),\n                    )\n                runSync {\n                    Bukkit.getPluginManager().callEvent(event)\n                }\n                val moduleEnabled = Config.moduleEnabled(\"old-tool-damage\", victim.world)\n                debugFile.parentFile?.mkdirs()\n                debugFile.appendText(\n                    \"label=$label base=${event.damage} raw=${event.damage} weapon=IRON_AXE enabled=$moduleEnabled\\n\",\n                )\n                return event.damage\n            } finally {\n                runSync {\n                    mob.remove()\n                    victim.remove()\n                }\n            }\n        }\n\n        test(\"mob tool damage follows configured old-tool-damage values\") {\n            withConfig {\n                val debugFile = resolveDebugFile()\n                debugFile.parentFile?.mkdirs()\n                debugFile.writeText(\"start\\n\")\n                try {\n                    ocm.config.set(\n                        \"disabled_modules\",\n                        ocm.config\n                            .getStringList(\"disabled_modules\")\n                            .filterNot { it.equals(\"old-tool-damage\", true) },\n                    )\n                    val oldModeset =\n                        ocm.config\n                            .getStringList(\"modesets.old\")\n                            .filterNot { it.equals(\"old-tool-damage\", true) }\n                            .toMutableList()\n                    oldModeset.add(\"old-tool-damage\")\n                    ocm.config.set(\"modesets.old\", oldModeset)\n                    ocm.config.set(\"old-tool-damage.damages.IRON_AXE\", 1)\n                    ocm.saveConfig()\n                    Config.reload()\n\n                    val lowDamage =\n                        try {\n                            captureVindicatorBaseDamage(debugFile, \"low\")\n                        } catch (e: Throwable) {\n                            debugFile.appendText(\"low-error=${e::class.java.simpleName}: ${e.message}\\n\")\n                            throw e\n                        }\n\n                    ocm.config.set(\"old-tool-damage.damages.IRON_AXE\", 20)\n                    ocm.saveConfig()\n                    Config.reload()\n\n                    val highDamage =\n                        try {\n                            captureVindicatorBaseDamage(debugFile, \"high\")\n                        } catch (e: Throwable) {\n                            debugFile.appendText(\"high-error=${e::class.java.simpleName}: ${e.message}\\n\")\n                            throw e\n                        }\n\n                    val delta = highDamage - lowDamage\n                    debugFile.appendText(\"delta=$delta low=$lowDamage high=$highDamage\\n\")\n                    if (delta <= 10.0) {\n                        error(\"Mob tool damage delta too small: delta=$delta low=$lowDamage high=$highDamage\")\n                    }\n                } catch (e: Throwable) {\n                    debugFile.appendText(\"test-error=${e::class.java.simpleName}: ${e.message}\\n\")\n                    throw e\n                }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PacketCancellationIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport com.github.retrooper.packetevents.PacketEvents\nimport com.github.retrooper.packetevents.event.PacketSendEvent\nimport com.github.retrooper.packetevents.manager.server.ServerVersion\nimport com.github.retrooper.packetevents.netty.buffer.ByteBufHelper\nimport com.github.retrooper.packetevents.protocol.ConnectionState\nimport com.github.retrooper.packetevents.protocol.PacketSide\nimport com.github.retrooper.packetevents.protocol.packettype.PacketType\nimport com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon\nimport com.github.retrooper.packetevents.protocol.particle.Particle\nimport com.github.retrooper.packetevents.protocol.particle.type.ParticleTypes\nimport com.github.retrooper.packetevents.protocol.sound.Sound\nimport com.github.retrooper.packetevents.protocol.sound.SoundCategory\nimport com.github.retrooper.packetevents.protocol.sound.Sounds\nimport com.github.retrooper.packetevents.protocol.player.User\nimport com.github.retrooper.packetevents.protocol.player.UserProfile\nimport com.github.retrooper.packetevents.util.Vector3d\nimport com.github.retrooper.packetevents.util.Vector3f\nimport com.github.retrooper.packetevents.util.Vector3i\nimport com.github.retrooper.packetevents.wrapper.PacketWrapper\nimport com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerParticle\nimport com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSoundEffect\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleAttackSounds\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweepParticles\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.entity.Player\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass PacketCancellationIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = Bukkit.getPluginManager().getPlugin(\"OldCombatMechanics\") as OCMMain\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()\n        }\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        require(PacketEvents.getAPI().isInitialized) { \"PacketEvents not initialised for integration tests.\" }\n    }\n\n    suspend fun withModuleState(\n        moduleName: String,\n        enabled: Boolean,\n        preReload: (() -> Unit)? = null,\n        postRestore: (() -> Unit)? = null,\n        block: suspend () -> Unit\n    ) {\n        val disabledOriginal = ocm.config.getStringList(\"disabled_modules\")\n        val alwaysOriginal = ocm.config.getStringList(\"always_enabled_modules\")\n        val modesetsSection = ocm.config.getConfigurationSection(\"modesets\")\n            ?: error(\"Missing modesets section in config\")\n        val modesetsOriginal = modesetsSection.getKeys(false).associateWith {\n            ocm.config.getStringList(\"modesets.$it\")\n        }\n\n        fun List<String>.withoutModule(): MutableList<String> =\n            filterNot { it.equals(moduleName, true) }.toMutableList()\n\n        val disabledUpdated = disabledOriginal.withoutModule()\n        val alwaysUpdated = alwaysOriginal.withoutModule()\n        if (enabled) {\n            alwaysUpdated.add(moduleName)\n        } else {\n            disabledUpdated.add(moduleName)\n        }\n\n        ocm.config.set(\"disabled_modules\", disabledUpdated)\n        ocm.config.set(\"always_enabled_modules\", alwaysUpdated)\n        modesetsOriginal.keys.forEach { key ->\n            val filtered = modesetsOriginal.getValue(key).withoutModule()\n            ocm.config.set(\"modesets.$key\", filtered)\n        }\n        preReload?.invoke()\n        ocm.saveConfig()\n        Config.reload()\n        delay(2 * 50L)\n\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"disabled_modules\", disabledOriginal)\n            ocm.config.set(\"always_enabled_modules\", alwaysOriginal)\n            modesetsOriginal.forEach { (key, value) ->\n                ocm.config.set(\"modesets.$key\", value)\n            }\n            postRestore?.invoke()\n            ocm.saveConfig()\n            Config.reload()\n            delay(2 * 50L)\n        }\n    }\n\n    fun spawnFakePlayer(): Pair<FakePlayer, Player> {\n        val world = Bukkit.getWorld(\"world\") ?: error(\"world not loaded\")\n        val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)\n        val fake = FakePlayer(testPlugin)\n        fake.spawn(location)\n        val player = Bukkit.getPlayer(fake.uuid) ?: error(\"Player not found after spawn\")\n        return fake to player\n    }\n\n    suspend fun removeFakePlayer(fake: FakePlayer) {\n        runSync { fake.removePlayer() }\n        delay(2 * 50L)\n    }\n\n    suspend fun withBlockedSounds(module: ModuleAttackSounds, blocked: Set<String>, block: suspend () -> Unit) {\n        val field = module.javaClass.getDeclaredField(\"blockedSounds\")\n        field.isAccessible = true\n        @Suppress(\"UNCHECKED_CAST\")\n        val current = field.get(module) as MutableSet<String>\n        val snapshot = current.toSet()\n        current.clear()\n        current.addAll(blocked)\n        try {\n            block()\n        } finally {\n            current.clear()\n            current.addAll(snapshot)\n        }\n    }\n\n    fun createPacketSendEvent(\n        packetId: Int,\n        packetType: PacketTypeCommon,\n        serverVersion: ServerVersion,\n        channel: Any,\n        user: User,\n        player: Player,\n        buffer: Any\n    ): PacketSendEvent {\n        val constructor = PacketSendEvent::class.java.getDeclaredConstructor(\n            Int::class.javaPrimitiveType,\n            PacketTypeCommon::class.java,\n            ServerVersion::class.java,\n            Any::class.java,\n            User::class.java,\n            Any::class.java,\n            Any::class.java\n        )\n        constructor.isAccessible = true\n        return constructor.newInstance(\n            packetId,\n            packetType,\n            serverVersion,\n            channel,\n            user,\n            player,\n            buffer\n        ) as PacketSendEvent\n    }\n\n    fun runPacketThroughPacketEvents(player: Player, wrapper: PacketWrapper<*>): Boolean {\n        val api = PacketEvents.getAPI()\n        val channel = api.playerManager.getChannel(player) ?: error(\"Missing channel for ${player.name}\")\n        val serverVersion = api.serverManager.version\n        val user = User(\n            channel,\n            ConnectionState.PLAY,\n            serverVersion.toClientVersion(),\n            UserProfile(player.uniqueId, player.name)\n        )\n        user.setEncoderState(ConnectionState.PLAY)\n        user.setDecoderState(ConnectionState.PLAY)\n        user.setEntityId(player.entityId)\n\n        val buffers = api.protocolManager.transformWrappers(wrapper, channel, true)\n        val buffer = buffers.firstOrNull() ?: error(\"No buffer produced for ${wrapper.javaClass.simpleName}\")\n        val packetId = ByteBufHelper.readVarInt(buffer)\n        val packetType = PacketType.getById(PacketSide.SERVER, ConnectionState.PLAY, user.clientVersion, packetId)\n            ?: error(\"No packet type for id $packetId in ${user.clientVersion}\")\n        val event = createPacketSendEvent(packetId, packetType, serverVersion, channel, user, player, buffer)\n\n        api.eventManager.callEvent(event)\n\n        return event.isCancelled\n    }\n\n    fun soundName(sound: Sound): String = sound.soundId.toString()\n\n    test(\"sweep particles are cancelled when module enabled\") {\n        withModuleState(\"disable-sword-sweep-particles\", enabled = true) {\n            val (fake, player) = runSync { spawnFakePlayer() }\n            try {\n                val particle = Particle(ParticleTypes.SWEEP_ATTACK)\n                val position = Vector3d(player.location.x, player.location.y, player.location.z)\n                val offset = Vector3f(0f, 0f, 0f)\n                val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)\n\n                val cancelled = runPacketThroughPacketEvents(player, wrapper)\n\n                cancelled shouldBe true\n            } finally {\n                removeFakePlayer(fake)\n            }\n        }\n    }\n\n    test(\"sweep particles are not cancelled when module disabled\") {\n        withModuleState(\"disable-sword-sweep-particles\", enabled = false) {\n            val (fake, player) = runSync { spawnFakePlayer() }\n            try {\n                val particle = Particle(ParticleTypes.FLAME)\n                val position = Vector3d(player.location.x, player.location.y, player.location.z)\n                val offset = Vector3f(0f, 0f, 0f)\n                val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)\n\n                val cancelled = runPacketThroughPacketEvents(player, wrapper)\n\n                cancelled shouldBe false\n            } finally {\n                removeFakePlayer(fake)\n            }\n        }\n    }\n\n    test(\"blocked attack sounds are cancelled when module enabled\") {\n        withModuleState(\"disable-attack-sounds\", enabled = true) {\n            val (fake, player) = runSync { spawnFakePlayer() }\n            try {\n                val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackSounds>().firstOrNull()\n                    ?: error(\"ModuleAttackSounds not registered\")\n                val sound = Sounds.ENTITY_PLAYER_ATTACK_STRONG\n                val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)\n                val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.37f, 0.71f)\n\n                withBlockedSounds(module, setOf(soundName(sound))) {\n                    val cancelled = runPacketThroughPacketEvents(player, wrapper)\n                    cancelled shouldBe true\n                }\n            } finally {\n                removeFakePlayer(fake)\n            }\n        }\n    }\n\n    test(\"non-blocked attack sounds are not cancelled when module enabled\") {\n        withModuleState(\"disable-attack-sounds\", enabled = true) {\n            val (fake, player) = runSync { spawnFakePlayer() }\n            try {\n                val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackSounds>().firstOrNull()\n                    ?: error(\"ModuleAttackSounds not registered\")\n                val blockedSound = Sounds.ENTITY_PLAYER_ATTACK_STRONG\n                val sound = Sounds.ENTITY_PLAYER_LEVELUP\n                val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)\n                val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.43f, 0.21f)\n\n                withBlockedSounds(module, setOf(soundName(blockedSound))) {\n                    val cancelled = runPacketThroughPacketEvents(player, wrapper)\n                    cancelled shouldBe false\n                }\n            } finally {\n                removeFakePlayer(fake)\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PaperSwordBlockingDamageReductionIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.shouldBeLessThan\nimport io.kotest.matchers.doubles.shouldBeGreaterThan\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.block.Action\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.player.PlayerInteractEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass PaperSwordBlockingDamageReductionIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler()\n            .callSyncMethod(testPlugin, Callable { action() })\n            .get()\n    }\n\n    suspend fun delayTicks(ticks: Long) {\n        delay(ticks * 50L)\n    }\n\n    fun paperDataComponentApiPresent(): Boolean {\n        return try {\n            Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\")\n            true\n        } catch (_: Throwable) {\n            false\n        }\n    }\n\n    fun setModeset(player: Player, name: String) {\n        val data = getPlayerData(player.uniqueId)\n        data.setModesetForWorld(player.world.uid, name)\n        setPlayerData(player.uniqueId, data)\n    }\n\n    fun equipSword(player: Player, material: Material) {\n        player.inventory.setItemInMainHand(ItemStack(material))\n        player.updateInventory()\n    }\n\n    fun rightClickMainHand(player: Player) {\n        val event = PlayerInteractEvent(\n            player,\n            Action.RIGHT_CLICK_AIR,\n            player.inventory.itemInMainHand,\n            null,\n            org.bukkit.block.BlockFace.SELF,\n            EquipmentSlot.HAND\n        )\n        Bukkit.getPluginManager().callEvent(event)\n    }\n\n    lateinit var defenderFake: FakePlayer\n    lateinit var defender: Player\n    lateinit var module: ModuleSwordBlocking\n\n    beforeSpec {\n        runSync {\n            module = ModuleLoader.getModules().filterIsInstance<ModuleSwordBlocking>().firstOrNull()\n                ?: error(\"ModuleSwordBlocking not registered\")\n\n            val world = Bukkit.getWorld(\"world\") ?: error(\"world missing\")\n            val base = Location(world, 0.0, 100.0, 0.0)\n\n            defenderFake = FakePlayer(testPlugin)\n            defenderFake.spawn(base)\n            defender = Bukkit.getPlayer(defenderFake.uuid) ?: error(\"defender not found\")\n\n            defender.gameMode = GameMode.SURVIVAL\n            defender.maximumNoDamageTicks = 20\n            defender.noDamageTicks = 0\n            defender.isInvulnerable = false\n            defender.inventory.clear()\n            setModeset(defender, \"old\")\n        }\n    }\n\n    afterSpec {\n        runSync {\n            defenderFake.removePlayer()\n        }\n    }\n\n    test(\"Paper sword blocking sets BLOCKING modifier negative on hit\") {\n        if (!paperDataComponentApiPresent()) {\n            println(\"Skipping: Paper DataComponent API not present\")\n            return@test\n        }\n\n        runSync {\n            equipSword(defender, Material.DIAMOND_SWORD)\n            defender.inventory.setItemInOffHand(ItemStack(Material.AIR))\n        }\n\n        runSync { rightClickMainHand(defender) }\n        delayTicks(2)\n\n        val offhandAfter = runSync { defender.inventory.itemInOffHand.type }\n        if (offhandAfter == Material.SHIELD) {\n            println(\"Skipping: legacy shield swap path is active on this server\")\n            return@test\n        }\n\n        runSync {\n            // The bug we saw in live logs: the sword can animate as BLOCK, but the server may not recognise\n            // it as \"blocking\" for damage reduction (BLOCKING stays 0). This asserts the Paper path is\n            // actually recognised server-side.\n            module.isPaperSwordBlocking(defender) shouldBe true\n        }\n\n        val zombie = runSync {\n            defender.world.spawn(defender.location.clone().add(0.0, 0.0, 1.0), org.bukkit.entity.Zombie::class.java)\n        }\n        try {\n            // Use a synthetic event here. We specifically care about the Paper sword-block detection and the\n            // computed reduction. Whether Bukkit considers the BLOCKING modifier \"applicable\" is decided by\n            // the server's internal damage pipeline and can differ for synthetic events vs real hits.\n            val event = runSync {\n                org.bukkit.event.entity.EntityDamageByEntityEvent(\n                    zombie,\n                    defender,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    2.5\n                )\n            }\n\n            val reduction = runSync { module.applyPaperBlockingReduction(event, 2.5) }\n            reduction shouldBeGreaterThan 0.0\n            // Also sanity-check sign: the module is supposed to write BLOCKING as a negative modifier downstream.\n            // (We don't assert it here because synthetic events may not expose/apply that modifier consistently.)\n            reduction shouldBeLessThan 2.5\n        } finally {\n            runSync {\n                zombie.remove()\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PlayerKnockbackIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModulePlayerKnockback\nimport com.cryptomorin.xseries.XAttribute\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.attribute.AttributeModifier\nimport org.bukkit.entity.Player\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.player.PlayerVelocityEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.util.Vector\nimport java.util.UUID\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass PlayerKnockbackIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModulePlayerKnockback>()\n        .firstOrNull() ?: error(\"ModulePlayerKnockback not registered\")\n\n    lateinit var attacker: Player\n    lateinit var victim: Player\n    lateinit var fakeAttacker: FakePlayer\n    lateinit var fakeVictim: FakePlayer\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {\n        val horizontal = ocm.config.getDouble(\"old-player-knockback.knockback-horizontal\")\n        val vertical = ocm.config.getDouble(\"old-player-knockback.knockback-vertical\")\n        val verticalLimit = ocm.config.getDouble(\"old-player-knockback.knockback-vertical-limit\")\n        val extraHorizontal = ocm.config.getDouble(\"old-player-knockback.knockback-extra-horizontal\")\n        val extraVertical = ocm.config.getDouble(\"old-player-knockback.knockback-extra-vertical\")\n        val resistanceEnabled = ocm.config.getBoolean(\"old-player-knockback.enable-knockback-resistance\")\n\n        try {\n            block()\n        } finally {\n            ocm.config.set(\"old-player-knockback.knockback-horizontal\", horizontal)\n            ocm.config.set(\"old-player-knockback.knockback-vertical\", vertical)\n            ocm.config.set(\"old-player-knockback.knockback-vertical-limit\", verticalLimit)\n            ocm.config.set(\"old-player-knockback.knockback-extra-horizontal\", extraHorizontal)\n            ocm.config.set(\"old-player-knockback.knockback-extra-vertical\", extraVertical)\n            ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", resistanceEnabled)\n            module.reload()\n            ModuleLoader.toggleModules()\n        }\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n    }\n\n    fun pendingKnockbackField(): java.lang.reflect.Field {\n        val names = listOf(\"pendingKnockback\", \"playerKnockbackHashMap\")\n        for (name in names) {\n            val f = runCatching { ModulePlayerKnockback::class.java.getDeclaredField(name) }.getOrNull() ?: continue\n            f.isAccessible = true\n            return f\n        }\n        error(\"No pending knockback field found on ModulePlayerKnockback (tried: $names)\")\n    }\n\n    fun pendingKnockbackMap(): MutableMap<UUID, Any> {\n        val field = pendingKnockbackField()\n        @Suppress(\"UNCHECKED_CAST\")\n        return field.get(module) as MutableMap<UUID, Any>\n    }\n\n    fun getPendingVector(uuid: UUID): Vector? {\n        val map = pendingKnockbackMap()\n        val value = map[uuid] ?: return null\n        return when (value) {\n            is Vector -> value\n            else -> {\n                val vf = value.javaClass.getDeclaredField(\"velocity\")\n                vf.isAccessible = true\n                vf.get(value) as? Vector\n            }\n        }\n    }\n\n    fun removePending(uuid: UUID) {\n        pendingKnockbackMap().remove(uuid)\n    }\n\n    fun putPending(uuid: UUID, vector: Vector) {\n        val fieldName = pendingKnockbackField().name\n        if (fieldName == \"playerKnockbackHashMap\") {\n            @Suppress(\"UNCHECKED_CAST\")\n            (pendingKnockbackMap() as MutableMap<UUID, Vector>)[uuid] = vector\n            return\n        }\n\n        val pendingClass = ModulePlayerKnockback::class.java.declaredClasses\n            .firstOrNull { it.simpleName == \"PendingKnockback\" }\n            ?: error(\"PendingKnockback inner class not found\")\n        val ctor = pendingClass.getDeclaredConstructor(Vector::class.java, Long::class.javaPrimitiveType)\n        ctor.isAccessible = true\n        val pending = ctor.newInstance(vector, Long.MAX_VALUE)\n        pendingKnockbackMap()[uuid] = pending\n    }\n\n    fun damageEvent(): EntityDamageByEntityEvent {\n        val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)\n        Bukkit.getPluginManager().callEvent(event)\n        return event\n    }\n\n    fun velocityEvent(initial: Vector): PlayerVelocityEvent {\n        val event = PlayerVelocityEvent(victim, initial)\n        Bukkit.getPluginManager().callEvent(event)\n        return event\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)\n            val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)\n\n            fakeAttacker = FakePlayer(testPlugin)\n            fakeVictim = FakePlayer(testPlugin)\n            fakeAttacker.spawn(attackerLocation)\n            fakeVictim.spawn(victimLocation)\n\n            attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))\n            victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))\n            attacker.isOp = true\n            victim.isOp = true\n            attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            setModeset(attacker, \"old\")\n            setModeset(victim, \"old\")\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakeAttacker.removePlayer()\n            fakeVictim.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)\n            val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)\n\n            attacker.teleport(attackerLocation)\n            victim.teleport(victimLocation)\n            attacker.isSprinting = false\n            attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            attacker.velocity = Vector(0, 0, 0)\n            victim.velocity = Vector(0, 0, 0)\n            victim.noDamageTicks = 0\n            victim.maximumNoDamageTicks = 0\n            victim.isInvulnerable = false\n            setModeset(attacker, \"old\")\n            setModeset(victim, \"old\")\n            module.reload()\n        }\n    }\n\n    context(\"Knockback vectors\") {\n        test(\"base knockback is applied on velocity event\") {\n            withConfig {\n                ocm.config.set(\"old-player-knockback.knockback-horizontal\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical-limit\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-extra-horizontal\", 0.5)\n                ocm.config.set(\"old-player-knockback.knockback-extra-vertical\", 0.1)\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", false)\n                module.reload()\n\n                damageEvent()\n                val vector = getPendingVector(victim.uniqueId) ?: error(\"No knockback stored\")\n                vector.x shouldBe (0.4 plusOrMinus 0.0001)\n                vector.y shouldBe (0.4 plusOrMinus 0.0001)\n                vector.z shouldBe (0.0 plusOrMinus 0.0001)\n                removePending(victim.uniqueId)\n            }\n        }\n\n        test(\"sprint adds extra knockback\") {\n            withConfig {\n                ocm.config.set(\"old-player-knockback.knockback-horizontal\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical-limit\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-extra-horizontal\", 0.5)\n                ocm.config.set(\"old-player-knockback.knockback-extra-vertical\", 0.1)\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", false)\n                module.reload()\n\n                attacker.isSprinting = true\n                attacker.teleport(attacker.location.apply { yaw = 0f })\n\n                val event = EntityDamageByEntityEvent(\n                    attacker,\n                    victim,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    4.0\n                )\n                module.onEntityDamageEntity(event)\n                val vector = getPendingVector(victim.uniqueId) ?: error(\"No knockback stored\")\n                vector.x shouldBe (0.4 plusOrMinus 0.0001)\n                vector.y shouldBe (0.5 plusOrMinus 0.0001)\n                vector.z shouldBe (0.5 plusOrMinus 0.0001)\n                removePending(victim.uniqueId)\n            }\n        }\n\n        test(\"velocity override only applies once\") {\n            withConfig {\n                ocm.config.set(\"old-player-knockback.knockback-horizontal\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical-limit\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-extra-horizontal\", 0.5)\n                ocm.config.set(\"old-player-knockback.knockback-extra-vertical\", 0.1)\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", false)\n                module.reload()\n\n                val expected = Vector(0.4, 0.4, 0.0)\n                putPending(victim.uniqueId, expected)\n                val first = velocityEvent(Vector(0, 0, 0))\n                first.velocity.x shouldBe (0.4 plusOrMinus 0.0001)\n\n                val secondInitial = Vector(1.0, 2.0, 3.0)\n                val second = velocityEvent(secondInitial)\n                second.velocity.x shouldBe (1.0 plusOrMinus 0.0001)\n                second.velocity.y shouldBe (2.0 plusOrMinus 0.0001)\n                second.velocity.z shouldBe (3.0 plusOrMinus 0.0001)\n            }\n        }\n    }\n\n    context(\"Knockback resistance\") {\n        test(\"modifiers are removed when resistance disabled\") {\n            withConfig {\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", false)\n                module.reload()\n\n                val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig\n                val attribute = victim.getAttribute(attributeType)\n                val modifier = AttributeModifier(UUID.randomUUID(), \"test\", 0.5, AttributeModifier.Operation.ADD_NUMBER)\n                attribute?.addModifier(modifier)\n\n                val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)\n                Bukkit.getPluginManager().callEvent(event)\n\n                attribute?.modifiers?.contains(modifier) shouldBe false\n            }\n        }\n\n        test(\"modifiers remain when resistance enabled and supported\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 16, 0)) return@test\n            withConfig {\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", true)\n                module.reload()\n\n                val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig\n                val attribute = victim.getAttribute(attributeType)\n                val modifier = AttributeModifier(UUID.randomUUID(), \"test\", 0.5, AttributeModifier.Operation.ADD_NUMBER)\n                attribute?.addModifier(modifier)\n\n                val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)\n                Bukkit.getPluginManager().callEvent(event)\n\n                attribute?.modifiers?.contains(modifier) shouldBe true\n            }\n        }\n\n        test(\"enabled resistance scales horizontal knockback\") {\n            if (!Reflector.versionIsNewerOrEqualTo(1, 16, 0)) return@test\n            withConfig {\n                ocm.config.set(\"old-player-knockback.knockback-horizontal\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-vertical-limit\", 0.4)\n                ocm.config.set(\"old-player-knockback.knockback-extra-horizontal\", 0.5)\n                ocm.config.set(\"old-player-knockback.knockback-extra-vertical\", 0.1)\n                ocm.config.set(\"old-player-knockback.enable-knockback-resistance\", true)\n                module.reload()\n\n                val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig\n                val attribute = victim.getAttribute(attributeType) ?: return@withConfig\n                val originalBase = attribute.baseValue\n                attribute.baseValue = 0.5\n\n                try {\n                    val event = damageEvent()\n                    event.isCancelled shouldBe false\n                    val vector = getPendingVector(victim.uniqueId) ?: error(\"No knockback stored\")\n                    val expectedHorizontal = 0.4 * (1 - attribute.value)\n                    vector.x shouldBe (expectedHorizontal plusOrMinus 0.0001)\n                    vector.y shouldBe (0.4 plusOrMinus 0.0001)\n                    removePending(victim.uniqueId)\n                } finally {\n                    attribute.baseValue = originalBase\n                }\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PlayerRegenIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModulePlayerRegen\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.entity.Player\nimport org.bukkit.event.entity.EntityRegainHealthEvent\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass PlayerRegenIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n    val module = ModuleLoader.getModules().filterIsInstance<ModulePlayerRegen>().firstOrNull()\n        ?: error(\"ModulePlayerRegen not registered\")\n\n    lateinit var player: Player\n    lateinit var fakePlayer: FakePlayer\n\n    fun <T> runSync(action: () -> T): T {\n        return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n            action()\n        }).get()\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n        ModuleLoader.toggleModules()\n    }\n\n    suspend fun withConfig(intervalMs: Long, amount: Int, exhaustion: Double, block: suspend () -> Unit) {\n        val oldInterval = ocm.config.getLong(\"old-player-regen.interval\")\n        val oldAmount = ocm.config.getInt(\"old-player-regen.amount\")\n        val oldExhaustion = ocm.config.getDouble(\"old-player-regen.exhaustion\")\n\n        try {\n            runSync {\n                ocm.config.set(\"old-player-regen.interval\", intervalMs)\n                ocm.config.set(\"old-player-regen.amount\", amount)\n                ocm.config.set(\"old-player-regen.exhaustion\", exhaustion)\n                module.reload()\n                ModuleLoader.toggleModules()\n            }\n            block()\n        } finally {\n            runSync {\n                ocm.config.set(\"old-player-regen.interval\", oldInterval)\n                ocm.config.set(\"old-player-regen.amount\", oldAmount)\n                ocm.config.set(\"old-player-regen.exhaustion\", oldExhaustion)\n                module.reload()\n                ModuleLoader.toggleModules()\n            }\n        }\n    }\n\n    fun createRegainEvent(player: Player, reason: EntityRegainHealthEvent.RegainReason, amount: Double): EntityRegainHealthEvent {\n        val ctors = EntityRegainHealthEvent::class.java.constructors\n        for (ctor in ctors) {\n            val paramTypes = ctor.parameterTypes\n            val args = arrayOfNulls<Any>(paramTypes.size)\n            var ok = true\n            for (i in paramTypes.indices) {\n                val t = paramTypes[i]\n                args[i] = when {\n                    org.bukkit.entity.Entity::class.java.isAssignableFrom(t) -> player\n                    t == java.lang.Double.TYPE || t == Double::class.java -> amount\n                    EntityRegainHealthEvent.RegainReason::class.java.isAssignableFrom(t) -> reason\n                    else -> null\n                }\n                if (args[i] == null && t.isPrimitive) {\n                    ok = false\n                    break\n                }\n            }\n            if (!ok) continue\n            try {\n                @Suppress(\"UNCHECKED_CAST\")\n                return ctor.newInstance(*args) as EntityRegainHealthEvent\n            } catch (_: Throwable) {\n                // Try next\n            }\n        }\n        error(\"No compatible EntityRegainHealthEvent constructor found for this server version\")\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getWorld(\"world\") ?: error(\"world not loaded\")\n            val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)\n            fakePlayer = FakePlayer(testPlugin)\n            fakePlayer.spawn(location)\n            player = Bukkit.getPlayer(fakePlayer.uuid) ?: error(\"Player not found\")\n            player.isOp = true\n            setModeset(player, \"old\")\n        }\n    }\n\n    afterSpec {\n        runSync { fakePlayer.removePlayer() }\n    }\n\n    beforeTest {\n        runSync {\n            setModeset(player, \"old\")\n            player.health = 20.0\n            player.exhaustion = 0f\n            player.saturation = 0f\n\n            // Ensure per-player state from previous tests does not leak (healTimes is keyed by UUID).\n            runCatching {\n                val names = listOf(\"lastHealTick\", \"healTimes\")\n                val f = names.asSequence()\n                    .mapNotNull { name -> runCatching { ModulePlayerRegen::class.java.getDeclaredField(name) }.getOrNull() }\n                    .firstOrNull()\n                if (f != null) {\n                    f.isAccessible = true\n                    @Suppress(\"UNCHECKED_CAST\")\n                    (f.get(module) as? MutableMap<Any, Any>)?.clear()\n                }\n            }\n        }\n    }\n\n    test(\"SATIATED regen is cancelled and replaced with configured heal + exhaustion\") {\n        withConfig(intervalMs = 0, amount = 2, exhaustion = 3.0) {\n            runSync {\n                player.health = 10.0\n                player.exhaustion = 1.0f\n            }\n\n            val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }\n            runSync {\n                module.onRegen(event)\n                event.isCancelled shouldBe true\n                player.health shouldBe (12.0 plusOrMinus 1e-9)\n\n                // Simulate vanilla modifying exhaustion despite the cancellation; OCM applies its own value next tick.\n                player.exhaustion = 2.0f\n            }\n\n            delay(2 * 50L)\n\n            runSync {\n                player.exhaustion.toDouble() shouldBe (4.0 plusOrMinus 0.0001) // previous(1.0) + config(3.0)\n            }\n        }\n    }\n\n    test(\"heal is skipped when within the configured interval\") {\n        withConfig(intervalMs = 60_000, amount = 2, exhaustion = 3.0) {\n            runSync {\n                player.health = 10.0\n                player.exhaustion = 1.0f\n            }\n\n            val first = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }\n            runSync {\n                module.onRegen(first)\n                player.health shouldBe (12.0 plusOrMinus 1e-9)\n            }\n\n            // Simulate immediate damage, then attempt to heal again within the interval.\n            runSync {\n                player.health = 10.0\n                player.exhaustion = 1.0f\n            }\n\n            val second = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }\n            runSync {\n                module.onRegen(second)\n                second.isCancelled shouldBe true\n                player.health shouldBe (10.0 plusOrMinus 1e-9)\n\n                // Simulate vanilla exhaustion change; the module should restore to previous exhaustion next tick.\n                player.exhaustion = 3.5f\n            }\n\n            delay(2 * 50L)\n\n            runSync {\n                player.exhaustion.toDouble() shouldBe (1.0 plusOrMinus 0.0001)\n            }\n        }\n    }\n\n    test(\"non-SATIATED regain reasons are not modified\") {\n        val nonSatiated = EntityRegainHealthEvent.RegainReason.values().firstOrNull {\n            it != EntityRegainHealthEvent.RegainReason.SATIATED\n        } ?: error(\"No non-SATIATED regain reason available\")\n\n        withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {\n            runSync {\n                player.health = 10.0\n                player.exhaustion = 1.0f\n            }\n            val event = runSync { createRegainEvent(player, nonSatiated, 5.0) }\n            runSync {\n                module.onRegen(event)\n                event.isCancelled shouldBe false\n                player.health shouldBe (10.0 plusOrMinus 1e-9)\n            }\n        }\n    }\n\n    test(\"healing is clamped to max health\") {\n        withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {\n            runSync { player.health = 19.5 }\n            val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }\n            runSync {\n                module.onRegen(event)\n                player.health shouldBe (20.0 plusOrMinus 1e-9)\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SpigotFunctionChooserIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.assertions.throwables.shouldThrow\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.StringSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser\nimport java.util.concurrent.atomic.AtomicInteger\n\n@OptIn(ExperimentalKotest::class)\nclass SpigotFunctionChooserIntegrationTest :\n    StringSpec({\n        \"compatibility failures choose fallback and stay cached\" {\n            val compatibilityFailures =\n                listOf<Throwable>(\n                    NoSuchMethodError(\"missing API method\"),\n                    NoClassDefFoundError(\"missing API class\"),\n                    AbstractMethodError(\"abstract API method\"),\n                    IncompatibleClassChangeError(\"binary incompatibility\"),\n                    CompatibilityUnsupportedOperationException(\"compatibility fallback approved\"),\n                )\n\n            compatibilityFailures.forEach { throwable ->\n                val apiCalls = AtomicInteger(0)\n                val fallbackCalls = AtomicInteger(0)\n\n                val chooser =\n                    SpigotFunctionChooser.apiCompatCall<String, Any, String>(\n                        { _, _ ->\n                            apiCalls.incrementAndGet()\n                            throw throwable\n                        },\n                        { _, _ ->\n                            fallbackCalls.incrementAndGet()\n                            \"fallback\"\n                        },\n                    )\n\n                chooser.apply(\"target\", Any()) shouldBe \"fallback\"\n                chooser.apply(\"target\", Any()) shouldBe \"fallback\"\n                apiCalls.get() shouldBe 1\n                fallbackCalls.get() shouldBe 2\n            }\n        }\n\n        \"ordinary logic failures are surfaced instead of choosing fallback\" {\n            val fallbackCalls = AtomicInteger(0)\n            val chooser =\n                SpigotFunctionChooser.apiCompatCall<String, Any, String>(\n                    { _, _ -> throw IllegalStateException(\"business logic failure\") },\n                    { _, _ ->\n                        fallbackCalls.incrementAndGet()\n                        \"fallback\"\n                    },\n                )\n\n            shouldThrow<IllegalStateException> {\n                chooser.apply(\"target\", Any())\n            }\n            fallbackCalls.get() shouldBe 0\n        }\n\n        \"null pointer failures are surfaced instead of choosing fallback\" {\n            val fallbackCalls = AtomicInteger(0)\n            val chooser =\n                SpigotFunctionChooser.apiCompatCall<String, Any, String>(\n                    { _, _ -> throw NullPointerException(\"unexpected null\") },\n                    { _, _ ->\n                        fallbackCalls.incrementAndGet()\n                        \"fallback\"\n                    },\n                )\n\n            shouldThrow<NullPointerException> {\n                chooser.apply(\"target\", Any())\n            }\n            fallbackCalls.get() shouldBe 0\n        }\n\n        \"generic unsupported operation with incompatible wording is surfaced\" {\n            val fallbackCalls = AtomicInteger(0)\n            val chooser =\n                SpigotFunctionChooser.apiCompatCall<String, Any, String>(\n                    { _, _ -> throw UnsupportedOperationException(\"incompatible state for this action\") },\n                    { _, _ ->\n                        fallbackCalls.incrementAndGet()\n                        \"fallback\"\n                    },\n                )\n\n            shouldThrow<UnsupportedOperationException> {\n                chooser.apply(\"target\", Any())\n            }\n            fallbackCalls.get() shouldBe 0\n        }\n    })\n\nprivate class CompatibilityUnsupportedOperationException(\n    message: String,\n) : UnsupportedOperationException(message)\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SwordBlockingIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.StringSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.GameMode\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.block.BlockFace\nimport org.bukkit.entity.Entity\nimport org.bukkit.entity.EntityType\nimport org.bukkit.entity.Item\nimport org.bukkit.entity.Player\nimport org.bukkit.event.block.Action\nimport org.bukkit.event.player.PlayerDropItemEvent\nimport org.bukkit.event.player.PlayerInteractAtEntityEvent\nimport org.bukkit.event.player.PlayerInteractEntityEvent\nimport org.bukkit.event.player.PlayerInteractEvent\nimport org.bukkit.event.player.PlayerItemHeldEvent\nimport org.bukkit.inventory.EquipmentSlot\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport org.bukkit.util.Vector\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass SwordBlockingIntegrationTest :\n    StringSpec({\n        val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n        val module =\n            ModuleLoader\n                .getModules()\n                .filterIsInstance<ModuleSwordBlocking>()\n                .firstOrNull() ?: error(\"ModuleSwordBlocking not registered\")\n        extension(MainThreadDispatcherExtension(plugin))\n        lateinit var player: Player\n        lateinit var fakePlayer: FakePlayer\n\n        fun <T> runSync(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(\n                        plugin,\n                        Callable {\n                            action()\n                        },\n                    ).get()\n            }\n\n        fun preparePlayer() {\n            println(\"Preparing player\")\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val location = Location(world, 0.0, 100.0, 0.0)\n\n            fakePlayer = FakePlayer(plugin)\n            fakePlayer.spawn(location)\n\n            player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))\n        }\n\n        beforeSpec {\n            Bukkit.getScheduler().runTask(\n                plugin,\n                Runnable {\n                    plugin.logger.info(\"Running before all\")\n                    preparePlayer()\n\n                    player.gameMode = GameMode.SURVIVAL\n                    player.maximumNoDamageTicks = 20\n                    player.noDamageTicks = 0 // remove spawn invulnerability\n                    player.isInvulnerable = false\n                },\n            )\n        }\n\n        fun rightClickWithMainHand() =\n            runSync {\n                val event =\n                    PlayerInteractEvent(\n                        player,\n                        Action.RIGHT_CLICK_AIR,\n                        player.inventory.itemInMainHand,\n                        null,\n                        BlockFace.SELF,\n                        EquipmentSlot.HAND,\n                    )\n                Bukkit.getPluginManager().callEvent(event)\n            }\n\n        fun rightClickEntity(\n            target: Entity,\n            hand: EquipmentSlot,\n        ) = runSync {\n            Bukkit.getPluginManager().callEvent(PlayerInteractEntityEvent(player, target, hand))\n        }\n\n        fun rightClickEntityAt(\n            target: Entity,\n            hand: EquipmentSlot,\n        ) = runSync {\n            Bukkit.getPluginManager().callEvent(PlayerInteractAtEntityEvent(player, target, Vector(0.0, 1.0, 0.0), hand))\n        }\n\n        fun spawnEntityTarget(): Entity =\n            runSync {\n                player.world.spawnEntity(player.location.clone().add(1.0, 0.0, 0.0), EntityType.VILLAGER)\n            }\n\n        fun forceRestoreViaHotbarChange() =\n            runSync {\n                val previous = player.inventory.heldItemSlot\n                val next = (previous + 1) % 9\n                Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, previous, next))\n                player.inventory.heldItemSlot = previous\n            }\n\n        fun paperConsumablePathAvailable(): Boolean =\n            try {\n                Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\")\n                val supportedField = ModuleSwordBlocking::class.java.getDeclaredField(\"paperSupported\")\n                supportedField.isAccessible = true\n                val adapterField = ModuleSwordBlocking::class.java.getDeclaredField(\"paperAdapter\")\n                adapterField.isAccessible = true\n                supportedField.getBoolean(module) && adapterField.get(module) != null\n            } catch (_: Throwable) {\n                false\n            }\n\n        suspend fun delayTicks(ticks: Long) {\n            delay(ticks * 50L)\n        }\n\n        suspend fun TestScope.withPaperAnimationEnabled(\n            enabled: Boolean,\n            block: suspend TestScope.() -> Unit,\n        ) {\n            val original = runSync { ocm.config.get(\"sword-blocking.paper-animation\") }\n            runSync {\n                ocm.config.set(\"sword-blocking.paper-animation\", enabled)\n                ocm.saveConfig()\n                Config.reload()\n            }\n            try {\n                block()\n            } finally {\n                runSync {\n                    ocm.config.set(\"sword-blocking.paper-animation\", original)\n                    ocm.saveConfig()\n                    Config.reload()\n                }\n            }\n        }\n\n        suspend fun TestScope.withUsePermission(\n            required: Boolean,\n            block: suspend TestScope.() -> Unit,\n        ) {\n            val original = runSync { ocm.config.getBoolean(\"sword-blocking.use-permission\") }\n            runSync {\n                ocm.config.set(\"sword-blocking.use-permission\", required)\n                module.reload()\n                ModuleLoader.toggleModules()\n            }\n            try {\n                block()\n            } finally {\n                runSync {\n                    ocm.config.set(\"sword-blocking.use-permission\", original)\n                    module.reload()\n                    ModuleLoader.toggleModules()\n                }\n            }\n        }\n\n        beforeTest {\n            runSync {\n                player.inventory.clear()\n                player.noDamageTicks = 0\n                player.maximumNoDamageTicks = 20\n                player.isInvulnerable = false\n            }\n        }\n\n        afterTest {\n            forceRestoreViaHotbarChange()\n            runSync { player.inventory.clear() }\n        }\n\n        afterSpec {\n            plugin.logger.info(\"Running after all\")\n            Bukkit.getScheduler().runTask(\n                plugin,\n                Runnable {\n                    fakePlayer.removePlayer()\n                },\n            )\n        }\n\n        \"adds blocking when right-clicking with a sword (shield on legacy, consumable on Paper)\" {\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            rightClickWithMainHand()\n            delayTicks(1)\n\n            runSync {\n                // Legacy path: module injects a shield (actual \"blocking\" state is client-driven).\n                // Paper path: offhand remains intact and a consumable-based use animation can surface as \"hand raised\".\n                (player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true\n                // Legacy path injects shield; paper path keeps offhand intact\n                setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true\n            }\n        }\n\n        \"paper-animation config false forces legacy shield fallback on Paper\" {\n            if (!paperConsumablePathAvailable()) {\n                println(\"Skipping: Paper consumable component path unavailable\")\n            } else {\n                withPaperAnimationEnabled(enabled = false) {\n                    runSync {\n                        player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                        player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                    }\n\n                    rightClickWithMainHand()\n                    delayTicks(1)\n\n                    runSync {\n                        player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n                    }\n                }\n            }\n        }\n\n        \"does not start blocking without a sword in the main hand\" {\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.STICK))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            rightClickWithMainHand()\n\n            runSync {\n                player.isBlocking shouldBe false\n                player.inventory.itemInOffHand.type shouldBe Material.AIR\n            }\n        }\n\n        \"starts blocking on main-hand entity right-click (shield on legacy, consumable on Paper)\" {\n            val target = spawnEntityTarget()\n            try {\n                runSync {\n                    player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                    player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                }\n\n                rightClickEntity(target, EquipmentSlot.HAND)\n                delayTicks(1)\n\n                runSync {\n                    (player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true\n                    setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true\n                }\n            } finally {\n                runSync { target.remove() }\n            }\n        }\n\n        \"does not start blocking on offhand entity right-click\" {\n            val target = spawnEntityTarget()\n            try {\n                runSync {\n                    player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                    player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                }\n\n                rightClickEntity(target, EquipmentSlot.OFF_HAND)\n                delayTicks(1)\n\n                runSync {\n                    player.isBlocking shouldBe false\n                    player.inventory.itemInOffHand.type shouldBe Material.AIR\n                }\n            } finally {\n                runSync { target.remove() }\n            }\n        }\n\n        \"entity interact plus interact-at should not duplicate side effects\" {\n            val originalOffhand = ItemStack(Material.APPLE)\n            val target = spawnEntityTarget()\n            try {\n                runSync {\n                    player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n                    player.inventory.setItemInOffHand(originalOffhand.clone())\n                }\n\n                rightClickEntity(target, EquipmentSlot.HAND)\n                rightClickEntityAt(target, EquipmentSlot.HAND)\n                delayTicks(1)\n\n                runSync {\n                    (player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true\n                    if (player.inventory.itemInOffHand.type == Material.SHIELD) {\n                        forceRestoreViaHotbarChange()\n                    }\n                    player.inventory.itemInOffHand.type shouldBe originalOffhand.type\n                }\n            } finally {\n                runSync { target.remove() }\n            }\n        }\n\n        \"restores the previous offhand item after a hotbar change (or leaves untouched on Paper path)\" {\n            val originalOffhand = ItemStack(Material.APPLE)\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n                player.inventory.setItemInOffHand(originalOffhand.clone())\n            }\n\n            rightClickWithMainHand()\n\n            runSync {\n                if (player.inventory.itemInOffHand.type == Material.SHIELD) {\n                    forceRestoreViaHotbarChange()\n                    player.isBlocking shouldBe false\n                    player.inventory.itemInOffHand.type shouldBe originalOffhand.type\n                } else {\n                    // Paper path: offhand never changed\n                    player.inventory.itemInOffHand.type shouldBe originalOffhand.type\n                }\n            }\n        }\n\n        \"cancels dropping the temporary shield and restores the stored item (legacy path only)\" {\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n            }\n\n            rightClickWithMainHand()\n\n            val dropped: Item = runSync { player.world.dropItem(player.location, ItemStack(Material.SHIELD)) }\n\n            runSync {\n                Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, dropped))\n            }\n\n            runSync {\n                if (player.inventory.itemInOffHand.type == Material.SHIELD) {\n                    player.inventory.itemInOffHand.type shouldBe Material.AIR\n                    player.isBlocking shouldBe false\n                } else {\n                    // Paper path: no injected shield; ensure we did not cancel normal state\n                    player.inventory.itemInOffHand.type shouldBe Material.AIR\n                }\n            }\n            runSync { dropped.remove() }\n        }\n\n        \"respects permission requirement when enabled\" {\n            withUsePermission(required = true) {\n                runSync {\n                    player.isOp = false\n                    player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n                    player.inventory.setItemInOffHand(ItemStack(Material.AIR))\n                }\n\n                rightClickWithMainHand()\n                delayTicks(1)\n\n                runSync {\n                    player.isBlocking shouldBe false\n                    player.inventory.itemInOffHand.type shouldBe Material.AIR\n                }\n\n                runSync { player.addAttachment(plugin, \"oldcombatmechanics.swordblock\", true) }\n                rightClickWithMainHand()\n                delayTicks(1)\n\n                runSync {\n                    // Legacy path injects a shield and sets isBlocking; Paper path keeps offhand intact and uses a\n                    // consumable-based use animation which can surface as \"hand raised\".\n                    (player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true\n                    setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true\n                }\n            }\n        }\n\n        \"does not replace an existing real shield in offhand\" {\n            val namedShield =\n                ItemStack(Material.SHIELD).apply {\n                    val meta = itemMeta\n                    meta?.setDisplayName(\"Real Shield\")\n                    itemMeta = meta\n                }\n\n            runSync {\n                player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))\n                player.inventory.setItemInOffHand(namedShield)\n            }\n\n            rightClickWithMainHand()\n\n            runSync {\n                val meta = player.inventory.itemInOffHand.itemMeta\n                meta?.displayName shouldBe \"Real Shield\"\n                player.inventory.itemInOffHand.type shouldBe Material.SHIELD\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SwordSweepIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweep\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass SwordSweepIntegrationTest : FunSpec({\n    val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n    val module = ModuleLoader.getModules()\n        .filterIsInstance<ModuleSwordSweep>()\n        .firstOrNull() ?: error(\"ModuleSwordSweep not registered\")\n\n    lateinit var attacker: Player\n    lateinit var victim: Player\n    lateinit var fakeAttacker: FakePlayer\n    lateinit var fakeVictim: FakePlayer\n\n    fun runSync(action: () -> Unit) {\n        if (Bukkit.isPrimaryThread()) {\n            action()\n        } else {\n            Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {\n                action()\n                null\n            }).get()\n        }\n    }\n\n    fun setModeset(player: Player, modeset: String) {\n        val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)\n        playerData.setModesetForWorld(player.world.uid, modeset)\n        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)\n    }\n\n    extensions(MainThreadDispatcherExtension(testPlugin))\n\n    beforeSpec {\n        runSync {\n            val world = Bukkit.getServer().getWorld(\"world\")\n            val attackerLocation = Location(world, 0.0, 100.0, 0.0)\n            val victimLocation = Location(world, 1.0, 100.0, 0.0)\n\n            fakeAttacker = FakePlayer(testPlugin)\n            fakeVictim = FakePlayer(testPlugin)\n            fakeAttacker.spawn(attackerLocation)\n            fakeVictim.spawn(victimLocation)\n\n            attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))\n            victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))\n            attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            setModeset(attacker, \"old\")\n            setModeset(victim, \"old\")\n            module.reload()\n        }\n    }\n\n    afterSpec {\n        runSync {\n            fakeAttacker.removePlayer()\n            fakeVictim.removePlayer()\n        }\n    }\n\n    beforeTest {\n        runSync {\n            attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))\n            setModeset(attacker, \"old\")\n            setModeset(victim, \"old\")\n            module.reload()\n        }\n    }\n\n    context(\"Sweep attack cancellation\") {\n        test(\"sweep attack is cancelled when enabled\") {\n            val sweepCause = EntityDamageEvent.DamageCause.values()\n                .firstOrNull { it.name == \"ENTITY_SWEEP_ATTACK\" }\n\n            if (sweepCause != null) {\n                val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)\n                Bukkit.getPluginManager().callEvent(event)\n                event.isCancelled shouldBe true\n            } else {\n                // Legacy (1.9): simulate sweep detection fallback (no dedicated cause)\n                val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)\n                    ?: 1.0f).toDouble()\n                val sweepDamage = 1.0 // matches ModuleSwordSweep fallback for level 0\n\n                // First, register the attacker location so the module can recognise the next hit as sweep\n                val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)\n                module.onEntityDamaged(priming)\n\n                val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)\n                module.onEntityDamaged(sweepEvent)\n                sweepEvent.isCancelled shouldBe true\n            }\n        }\n\n        test(\"non-sweep attack is not cancelled\") {\n            val event = EntityDamageByEntityEvent(\n                attacker,\n                victim,\n                EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                1.0\n            )\n            Bukkit.getPluginManager().callEvent(event)\n            event.isCancelled shouldBe false\n        }\n\n        test(\"disabled module does not cancel sweep\") {\n            setModeset(attacker, \"new\")\n            val sweepCause = EntityDamageEvent.DamageCause.values()\n                .firstOrNull { it.name == \"ENTITY_SWEEP_ATTACK\" }\n\n            if (sweepCause != null) {\n                val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)\n                module.onEntityDamaged(event)\n                event.isCancelled shouldBe false\n            } else {\n                val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)\n                    ?: 1.0f).toDouble()\n                val sweepDamage = 1.0\n\n                val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)\n                module.onEntityDamaged(priming)\n\n                val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)\n                module.onEntityDamaged(sweepEvent)\n                sweepEvent.isCancelled shouldBe false\n            }\n        }\n    }\n})\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/Tally.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\n/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics\n\nclass Tally {\n    var passed: Int = 0\n        private set\n    var failed: Int = 0\n        private set\n\n    fun passed() {\n        passed++\n    }\n\n    fun failed() {\n        failed++\n    }\n\n    val total: Int\n        get() = passed + failed\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/TestResultWriter.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport org.bukkit.Bukkit\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.io.File\nimport java.util.logging.Level\n\nobject TestResultWriter {\n    @JvmStatic\n    fun writeAndShutdown(plugin: JavaPlugin, success: Boolean, error: Throwable? = null) {\n        try {\n            val resultFile = File(plugin.dataFolder, \"test-results.txt\")\n            resultFile.parentFile.mkdirs()\n            resultFile.writeText(if (success) \"PASS\" else \"FAIL\")\n            plugin.logger.info(\"Test result written to ${resultFile.absolutePath}\")\n        } catch (e: Exception) {\n            plugin.logger.log(Level.SEVERE, \"Failed to write test results file.\", e)\n        }\n\n        if (error != null) {\n            plugin.logger.log(Level.SEVERE, \"Integration tests failed.\", error)\n        }\n\n        Bukkit.shutdown()\n    }\n\n    @JvmStatic\n    fun writeFailureSummary(plugin: JavaPlugin, lines: List<String>) {\n        try {\n            val file = File(plugin.dataFolder, \"test-failures.txt\")\n            file.parentFile.mkdirs()\n            file.writeText(lines.joinToString(separator = \"\\n\", postfix = if (lines.isEmpty()) \"\" else \"\\n\"))\n        } catch (e: Exception) {\n            plugin.logger.log(Level.SEVERE, \"Failed to write test failures file.\", e)\n        }\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/TesterUtils.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\n/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics\n\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger.send\nimport org.bukkit.command.CommandSender\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.potion.PotionEffect\nimport org.bukkit.potion.PotionEffectType\n\nobject TesterUtils {\n    /**\n     * Checks whether the two values are equal, prints the result and updates the tally\n     *\n     * @param a        The expected value\n     * @param b        The actual value\n     * @param tally    The tally to update the result of the test with\n     * @param testName The name of the test being run\n     * @param senders  The command senders to message with the result of the test\n     */\n    fun assertEquals(a: Float, b: Float, tally: Tally, testName: String, vararg senders: CommandSender) {\n        // Due to cooldown effects, numbers can be very close (e.g. 1.0000000149011612 == 1.0)\n        // These are equivalent when using floats, which is what the server is using anyway\n        if (a == b) {\n            tally.passed()\n            for (sender in senders) send(\n                sender,\n                \"&aPASSED &f$testName [E: $a / A: $b]\"\n            )\n        } else {\n            tally.failed()\n            for (sender in senders) send(\n                sender,\n                \"&cFAILED &f$testName [E: $a / A: $b]\"\n            )\n        }\n    }\n\n    /**\n     * Cross-version accessor for a specific potion effect. Pre-1.12 servers lack\n     * LivingEntity#getPotionEffect, so we fall back to scanning active effects.\n     */\n    fun LivingEntity.getPotionEffectCompat(type: PotionEffectType): PotionEffect? {\n        // Prefer reflection to avoid linkage errors on legacy servers.\n        val method = javaClass.methods.firstOrNull { m ->\n            m.name == \"getPotionEffect\" &&\n                m.parameterTypes.size == 1 &&\n                m.parameterTypes[0] == PotionEffectType::class.java\n        }\n        return runCatching { method?.invoke(this, type) as PotionEffect? }.getOrNull()\n            ?: activePotionEffects.firstOrNull { it.type == type }\n    }\n}\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ToolDamageTooltipIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport io.kotest.core.test.TestScope\nimport io.kotest.matchers.doubles.plusOrMinus\nimport io.kotest.matchers.shouldBe\nimport kernitus.plugin.OldCombatMechanics.utilities.Config\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.ChatColor\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.player.PlayerItemHeldEvent\nimport org.bukkit.event.player.PlayerJoinEvent\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.util.concurrent.Callable\n\n@OptIn(ExperimentalKotest::class)\nclass ToolDamageTooltipIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        val ocm = JavaPlugin.getPlugin(OCMMain::class.java)\n\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun runSync(action: () -> Unit) {\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(\n                        testPlugin,\n                        Callable {\n                            action()\n                            null\n                        },\n                    ).get()\n            }\n        }\n\n        fun <T> runSyncAndGet(action: () -> T): T =\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()\n            }\n\n        suspend fun delayTicks(ticks: Long) {\n            delay(ticks * 50L)\n        }\n\n        val lorePrefix = \"OCM Damage:\"\n\n        fun stripColour(line: String): String = ChatColor.stripColor(line) ?: line\n\n        fun findOcmLines(item: ItemStack): List<String> {\n            val lore = item.itemMeta?.lore ?: emptyList()\n            return lore.filter { stripColour(it).startsWith(lorePrefix) }\n        }\n\n        fun parseFirstDamage(item: ItemStack): Double? {\n            val line = findOcmLines(item).firstOrNull() ?: return null\n            val stripped = stripColour(line)\n            val match = Regex(\"(-?\\\\d+(?:\\\\.\\\\d+)?)\").find(stripped) ?: return null\n            return match.value.toDoubleOrNull()\n        }\n\n        fun setLore(\n            item: ItemStack,\n            lines: List<String>?,\n        ) {\n            val meta = item.itemMeta ?: return\n            meta.lore = lines\n            item.itemMeta = meta\n        }\n\n        data class SpawnedPlayer(\n            val fake: FakePlayer,\n            val player: Player,\n        )\n\n        fun spawnFake(location: Location): SpawnedPlayer {\n            lateinit var fake: FakePlayer\n            lateinit var player: Player\n            runSync {\n                fake = FakePlayer(testPlugin)\n                fake.spawn(location)\n                player = checkNotNull(Bukkit.getPlayer(fake.uuid))\n                player.inventory.clear()\n                player.isInvulnerable = false\n                player.activePotionEffects.forEach { player.removePotionEffect(it.type) }\n                val data =\n                    kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\n                        .getPlayerData(player.uniqueId)\n                data.setModesetForWorld(player.world.uid, \"old\")\n                kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\n                    .setPlayerData(player.uniqueId, data)\n            }\n            return SpawnedPlayer(fake, player)\n        }\n\n        fun cleanup(vararg players: SpawnedPlayer) {\n            runSync { players.forEach { it.fake.removePlayer() } }\n        }\n\n        suspend fun TestScope.withConfig(\n            weaponMaterialKey: String,\n            weaponDamage: Double,\n            block: suspend TestScope.() -> Unit,\n        ) {\n            val disabledModules = ocm.config.getStringList(\"disabled_modules\")\n            val modesetsSection = ocm.config.getConfigurationSection(\"modesets\") ?: error(\"Missing 'modesets' section in config\")\n            val modesetSnapshot =\n                modesetsSection.getKeys(false).associateWith { key ->\n                    ocm.config.getStringList(\"modesets.$key\")\n                }\n            val damagesSnapshot =\n                ocm.config\n                    .getConfigurationSection(\"old-tool-damage.damages\")\n                    ?.getValues(false)\n                    ?: emptyMap<String, Any?>()\n            val tooltipEnabledSnapshot = ocm.config.get(\"old-tool-damage.tooltip.enabled\")\n            val tooltipPrefixSnapshot = ocm.config.get(\"old-tool-damage.tooltip.prefix\")\n\n            fun reloadAll() {\n                ocm.saveConfig()\n                Config.reload()\n                WeaponDamages.initialise(ocm)\n                ModuleLoader.toggleModules()\n            }\n\n            try {\n                ocm.config.set(\"old-tool-damage.damages.$weaponMaterialKey\", weaponDamage)\n                ocm.config.set(\"old-tool-damage.tooltip.enabled\", true)\n                ocm.config.set(\"old-tool-damage.tooltip.prefix\", lorePrefix)\n                ocm.config.set(\"disabled_modules\", disabledModules.filterNot { it == \"old-tool-damage\" })\n                val oldModeset = ocm.config.getStringList(\"modesets.old\").toMutableList()\n                if (!oldModeset.contains(\"old-tool-damage\")) {\n                    oldModeset.add(\"old-tool-damage\")\n                }\n                ocm.config.set(\"modesets.old\", oldModeset)\n                reloadAll()\n                block()\n            } finally {\n                ocm.config.set(\"disabled_modules\", disabledModules)\n                modesetSnapshot.forEach { (key, list) -> ocm.config.set(\"modesets.$key\", list) }\n                ocm.config.set(\"old-tool-damage.damages\", null)\n                damagesSnapshot.forEach { (k, v) -> ocm.config.set(\"old-tool-damage.damages.$k\", v) }\n                ocm.config.set(\"old-tool-damage.tooltip.enabled\", tooltipEnabledSnapshot)\n                ocm.config.set(\"old-tool-damage.tooltip.prefix\", tooltipPrefixSnapshot)\n                reloadAll()\n            }\n        }\n\n        fun fireJoin(player: Player) {\n            Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, \"test\"))\n        }\n\n        fun switchHotbar(\n            player: Player,\n            from: Int,\n            to: Int,\n        ) {\n            player.inventory.heldItemSlot = to\n            Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))\n        }\n\n        test(\"adds a tooltip lore line for configured vanilla weapon damage\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                runSync {\n                    p.player.inventory.setItem(0, sword)\n                    p.player.inventory.setItem(1, ItemStack(Material.STICK))\n                    p.player.inventory.heldItemSlot = 1\n                }\n\n                runSync { switchHotbar(p.player, from = 1, to = 0) }\n\n                val held =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                val loreLines = findOcmLines(held)\n                loreLines.size shouldBe 1\n                parseFirstDamage(held) shouldBe (7.0 plusOrMinus 0.01)\n                cleanup(p)\n            }\n        }\n\n        test(\"does not duplicate the tooltip lore line when applied repeatedly\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                runSync {\n                    p.player.inventory.setItemInMainHand(sword)\n                }\n\n                runSync {\n                    fireJoin(p.player)\n                    fireJoin(p.player)\n                }\n\n                val held =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                val loreLines = findOcmLines(held)\n                loreLines.size shouldBe 1\n                cleanup(p)\n            }\n        }\n\n        test(\"preserves existing lore when adding the tooltip line\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                runSync {\n                    setLore(sword, listOf(\"OtherPlugin: Example\"))\n                    p.player.inventory.setItemInMainHand(sword)\n                    fireJoin(p.player)\n                }\n\n                val held =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                val lore = held.itemMeta?.lore ?: emptyList()\n                lore.any { stripColour(it) == \"OtherPlugin: Example\" } shouldBe true\n                findOcmLines(held).size shouldBe 1\n                cleanup(p)\n            }\n        }\n\n        test(\"updates tooltip damage after config reload\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                runSync {\n                    p.player.inventory.setItemInMainHand(sword)\n                    fireJoin(p.player)\n                }\n                runSyncAndGet { parseFirstDamage(p.player.inventory.itemInMainHand) } shouldBe (7.0 plusOrMinus 0.01)\n\n                runSync {\n                    ocm.config.set(\"old-tool-damage.damages.DIAMOND_SWORD\", 9.0)\n                    ocm.saveConfig()\n                    Config.reload()\n                    WeaponDamages.initialise(ocm)\n                    ModuleLoader.toggleModules()\n                    fireJoin(p.player)\n                }\n\n                val held =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                findOcmLines(held).size shouldBe 1\n                parseFirstDamage(held) shouldBe (9.0 plusOrMinus 0.01)\n                cleanup(p)\n            }\n        }\n\n        test(\"cleans the tooltip lore line when the module is disabled\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                runSync {\n                    p.player.inventory.setItem(0, sword)\n                    p.player.inventory.setItem(1, ItemStack(Material.STICK))\n                    p.player.inventory.heldItemSlot = 1\n                    switchHotbar(p.player, from = 1, to = 0)\n                }\n                runSyncAndGet {\n                    val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)\n                    findOcmLines(slot0).size\n                } shouldBe 1\n\n                runSync {\n                    val data =\n                        kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\n                            .getPlayerData(p.player.uniqueId)\n                    data.setModesetForWorld(p.player.world.uid, \"new\")\n                    kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage\n                        .setPlayerData(p.player.uniqueId, data)\n                    switchHotbar(p.player, from = 0, to = 1) // should clean the old hand\n                }\n                delayTicks(1)\n\n                runSyncAndGet {\n                    val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)\n                    findOcmLines(slot0).size\n                } shouldBe 0\n                cleanup(p)\n            }\n        }\n\n        test(\"does not add a tooltip lore line for non-weapons\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val stick = ItemStack(Material.STICK)\n                runSync {\n                    p.player.inventory.setItemInMainHand(stick)\n                    fireJoin(p.player)\n                }\n\n                findOcmLines(stick).size shouldBe 0\n                cleanup(p)\n            }\n        }\n\n        test(\"swap hand items applies tooltip to new main hand and keeps offhand clean\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n                val stick = ItemStack(Material.STICK)\n                runSync {\n                    p.player.inventory.setItemInMainHand(stick)\n                    p.player.inventory.setItemInOffHand(sword)\n                    val swap = PlayerSwapHandItemsEvent(p.player, stick, sword)\n                    Bukkit.getPluginManager().callEvent(swap)\n                }\n\n                findOcmLines(sword).size shouldBe 1\n                findOcmLines(stick).size shouldBe 0\n                cleanup(p)\n            }\n        }\n\n        test(\"swap hand finalisation keeps tooltip only on new main-hand weapon\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                runSync {\n                    p.player.inventory.setItemInMainHand(ItemStack(Material.STICK))\n                    p.player.inventory.setItemInOffHand(ItemStack(Material.DIAMOND_SWORD))\n\n                    val swap =\n                        PlayerSwapHandItemsEvent(\n                            p.player,\n                            p.player.inventory.itemInMainHand,\n                            p.player.inventory.itemInOffHand,\n                        )\n                    Bukkit.getPluginManager().callEvent(swap)\n\n                    val newMainHand = swap.offHandItem?.clone() ?: ItemStack(Material.AIR)\n                    val newOffHand = swap.mainHandItem?.clone() ?: ItemStack(Material.AIR)\n                    p.player.inventory.setItemInMainHand(newMainHand)\n                    p.player.inventory.setItemInOffHand(newOffHand)\n                }\n\n                val mainHand =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                val offHand =\n                    runSyncAndGet {\n                        p.player.inventory.itemInOffHand\n                            .clone()\n                    }\n                mainHand.type shouldBe Material.DIAMOND_SWORD\n                offHand.type shouldBe Material.STICK\n                findOcmLines(mainHand).size shouldBe 1\n                findOcmLines(offHand).size shouldBe 0\n                cleanup(p)\n            }\n        }\n\n        test(\"plays nicely with a lore-rewriting plugin (other lore preserved, no duplication)\") {\n            withConfig(weaponMaterialKey = \"DIAMOND_SWORD\", weaponDamage = 7.0) {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                val p = spawnFake(Location(world, 0.0, 100.0, 0.0))\n                val sword = ItemStack(Material.DIAMOND_SWORD)\n\n                val otherPlugin =\n                    object : Listener {\n                        @EventHandler(priority = EventPriority.LOWEST)\n                        fun onJoin(event: PlayerJoinEvent) {\n                            if (event.player != p.player) return\n                            val item = event.player.inventory.itemInMainHand\n                            if (item.type != Material.DIAMOND_SWORD) return\n                            setLore(item, listOf(\"OtherPlugin: Rewritten\"))\n                        }\n\n                        @EventHandler(priority = EventPriority.LOWEST)\n                        fun onHeld(event: PlayerItemHeldEvent) {\n                            if (event.player != p.player) return\n                            val item = event.player.inventory.itemInMainHand\n                            if (item.type != Material.DIAMOND_SWORD) return\n                            setLore(item, listOf(\"OtherPlugin: Rewritten\"))\n                        }\n                    }\n\n                runSync {\n                    Bukkit.getPluginManager().registerEvents(otherPlugin, testPlugin)\n                    p.player.inventory.setItemInMainHand(sword)\n                    fireJoin(p.player)\n                    fireJoin(p.player)\n                    HandlerList.unregisterAll(otherPlugin)\n                }\n\n                val held =\n                    runSyncAndGet {\n                        p.player.inventory.itemInMainHand\n                            .clone()\n                    }\n                val lore = held.itemMeta?.lore ?: emptyList()\n                lore.any { stripColour(it) == \"OtherPlugin: Rewritten\" } shouldBe true\n                findOcmLines(held).size shouldBe 1\n                cleanup(p)\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/WeaponDurabilityIntegrationTest.kt",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics\n\nimport io.kotest.common.ExperimentalKotest\nimport io.kotest.core.spec.style.FunSpec\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData\nimport kotlinx.coroutines.delay\nimport org.bukkit.Bukkit\nimport org.bukkit.Location\nimport org.bukkit.Material\nimport org.bukkit.entity.LivingEntity\nimport org.bukkit.entity.Player\nimport org.bukkit.event.EventHandler\nimport org.bukkit.event.EventPriority\nimport org.bukkit.event.HandlerList\nimport org.bukkit.event.Listener\nimport org.bukkit.event.entity.EntityDamageByEntityEvent\nimport org.bukkit.event.entity.EntityDamageEvent\nimport org.bukkit.event.player.PlayerItemDamageEvent\nimport org.bukkit.inventory.ItemStack\nimport org.bukkit.plugin.java.JavaPlugin\nimport java.io.File\nimport java.util.concurrent.Callable\nimport java.util.concurrent.atomic.AtomicInteger\n\n@OptIn(ExperimentalKotest::class)\nclass WeaponDurabilityIntegrationTest :\n    FunSpec({\n        val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)\n        extensions(MainThreadDispatcherExtension(testPlugin))\n\n        fun runSync(action: () -> Unit) {\n            if (Bukkit.isPrimaryThread()) {\n                action()\n            } else {\n                Bukkit\n                    .getScheduler()\n                    .callSyncMethod(\n                        testPlugin,\n                        Callable {\n                            action()\n                            null\n                        },\n                    ).get()\n            }\n        }\n\n        suspend fun delayTicks(ticks: Long) {\n            delay(ticks * 50L)\n        }\n\n        fun scoreAttackMethodLocal(method: java.lang.reflect.Method): Int {\n            var score = 0\n            val name = method.name\n            val param = method.parameterTypes[0]\n            val declaring = method.declaringClass.simpleName\n\n            if (name == \"attack\") score += 100\n            if (name == \"a\") score += 80\n\n            if (param.simpleName == \"Entity\") score += 40\n            if (param.simpleName.contains(\"Entity\")) score += 10\n\n            if (method.returnType == Void.TYPE) score += 10\n            if (method.returnType == java.lang.Boolean.TYPE) score += 8\n\n            if (declaring.contains(\"EntityHuman\")) score += 25\n            if (declaring.contains(\"EntityPlayer\")) score += 20\n\n            return score\n        }\n\n        fun methodSignatureLocal(method: java.lang.reflect.Method): String {\n            val params = method.parameterTypes.joinToString(\",\") { it.name }\n            return \"${method.declaringClass.name}#${method.name}($params):${method.returnType.name}\"\n        }\n\n        fun collectAllMethods(start: Class<*>): List<java.lang.reflect.Method> {\n            val methods = LinkedHashMap<String, java.lang.reflect.Method>()\n            var current: Class<*>? = start\n            while (current != null) {\n                current.declaredMethods.forEach { method ->\n                    methods.putIfAbsent(methodSignatureLocal(method), method)\n                }\n                current = current.superclass\n            }\n            start.methods.forEach { method ->\n                methods.putIfAbsent(methodSignatureLocal(method), method)\n            }\n            return methods.values.toList()\n        }\n\n        fun attackNms(\n            attacker: Player,\n            target: LivingEntity,\n        ) {\n            runCatching {\n                attacker.attack(target)\n                return\n            }\n            val attackerHandle =\n                attacker.javaClass.methods\n                    .firstOrNull { method ->\n                        method.name == \"getHandle\" && method.parameterCount == 0\n                    }?.invoke(attacker) ?: error(\"Failed to resolve CraftPlayer#getHandle for attacker\")\n\n            val targetHandle =\n                target.javaClass.methods\n                    .firstOrNull { method ->\n                        method.name == \"getHandle\" && method.parameterCount == 0\n                    }?.invoke(target) ?: error(\"Failed to resolve CraftPlayer#getHandle for target\")\n\n            val attackerHandleClass = attackerHandle.javaClass\n            val targetHandleClass = targetHandle.javaClass\n\n            runCatching {\n                val managerField =\n                    attackerHandleClass.declaredFields.firstOrNull { field ->\n                        field.type.simpleName.contains(\"GameMode\") || field.type.simpleName.contains(\"InteractManager\")\n                    }\n                if (managerField != null) {\n                    managerField.isAccessible = true\n                    val manager = managerField.get(attackerHandle) ?: return@runCatching\n                    val attackMethod =\n                        manager.javaClass.methods.firstOrNull { method ->\n                            (method.name == \"attack\" || method.name == \"a\") &&\n                                method.parameterCount == 1 &&\n                                method.parameterTypes[0].isAssignableFrom(targetHandleClass)\n                        }\n                    if (attackMethod != null) {\n                        attackMethod.isAccessible = true\n                        attackMethod.invoke(manager, targetHandle)\n                        return\n                    }\n                }\n            }\n            val candidates =\n                listOfNotNull(\n                    kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(\n                        attackerHandleClass,\n                        \"attack\",\n                        targetHandleClass,\n                    ),\n                    kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(\n                        attackerHandleClass,\n                        \"a\",\n                        targetHandleClass,\n                    ),\n                ).ifEmpty {\n                    collectAllMethods(attackerHandleClass)\n                        .asSequence()\n                        .filter { it.parameterCount == 1 }\n                        .filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }\n                        .filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }\n                        .map { method -> method to scoreAttackMethodLocal(method) }\n                        .sortedByDescending { it.second }\n                        .map { it.first }\n                        .toList()\n                }\n\n            candidates.forEach { it.isAccessible = true }\n            for (method in candidates) {\n                try {\n                    val result = method.invoke(attackerHandle, targetHandle)\n                    if (result is Boolean && !result) continue\n                    return\n                } catch (ignored: Exception) {\n                    // try next\n                }\n            }\n            error(\"Failed to invoke NMS attack for FakePlayer attacker=${attackerHandleClass.name}\")\n        }\n\n        fun resolveDebugFile(): File {\n            val versionTag = Bukkit.getBukkitVersion().replace(Regex(\"[^A-Za-z0-9_.-]\"), \"_\")\n            val runDir = File(System.getProperty(\"user.dir\"))\n            val repoRoot = runDir.parentFile?.parentFile ?: runDir\n            return File(repoRoot, \"build/weapon-durability-debug-$versionTag.txt\")\n        }\n\n        fun appendDebug(line: String) {\n            val file = resolveDebugFile()\n            file.parentFile?.mkdirs()\n            file.appendText(line + \"\\n\")\n        }\n\n        fun describeNmsState(\n            attacker: Player,\n            victim: LivingEntity,\n        ): String {\n            return runCatching {\n                val attackerHandle =\n                    attacker.javaClass.methods\n                        .firstOrNull { it.name == \"getHandle\" && it.parameterCount == 0 }\n                        ?.invoke(attacker) ?: return@runCatching \"noAttackerHandle\"\n                val victimHandle =\n                    victim.javaClass.methods\n                        .firstOrNull { it.name == \"getHandle\" && it.parameterCount == 0 }\n                        ?.invoke(victim) ?: return@runCatching \"noVictimHandle\"\n\n                fun flag(\n                    handle: Any,\n                    name: String,\n                ): String? {\n                    val method = handle.javaClass.methods.firstOrNull { it.name == name && it.parameterCount == 0 }\n                    val value = method?.invoke(handle)\n                    return value?.toString()\n                }\n\n                val attackerAlive = flag(attackerHandle, \"isAlive\")\n                val victimAlive = flag(victimHandle, \"isAlive\")\n                val victimRemoved = flag(victimHandle, \"isRemoved\")\n                \"attackerAlive=$attackerAlive victimAlive=$victimAlive victimRemoved=$victimRemoved\"\n            }.getOrElse { \"nmsErr=${it::class.java.simpleName}\" }\n        }\n\n        fun setItemDamage(\n            item: ItemStack,\n            damage: Int,\n        ) {\n            val meta = item.itemMeta\n            if (meta != null) {\n                try {\n                    val damageableClass = Class.forName(\"org.bukkit.inventory.meta.Damageable\")\n                    if (damageableClass.isInstance(meta)) {\n                        val setDamage = damageableClass.getMethod(\"setDamage\", Int::class.javaPrimitiveType)\n                        setDamage.invoke(meta, damage)\n                        item.itemMeta = meta\n                        return\n                    }\n                } catch (ignored: ClassNotFoundException) {\n                    // Legacy server, fall back to durability.\n                }\n            }\n            @Suppress(\"DEPRECATION\")\n            item.durability = damage.toShort()\n        }\n\n        fun getItemDamage(item: ItemStack): Int {\n            val meta = item.itemMeta\n            if (meta != null) {\n                try {\n                    val damageableClass = Class.forName(\"org.bukkit.inventory.meta.Damageable\")\n                    if (damageableClass.isInstance(meta)) {\n                        val getDamage = damageableClass.getMethod(\"getDamage\")\n                        return (getDamage.invoke(meta) as Number).toInt()\n                    }\n                } catch (ignored: ClassNotFoundException) {\n                    // Legacy server, fall back to durability.\n                }\n            }\n            @Suppress(\"DEPRECATION\")\n            return item.durability.toInt()\n        }\n\n        fun setOldModeset(player: Player) {\n            val playerData = getPlayerData(player.uniqueId)\n            playerData.setModesetForWorld(player.world.uid, \"old\")\n            setPlayerData(player.uniqueId, playerData)\n        }\n\n        suspend fun withAttackerAndVictim(block: suspend (attacker: Player, victim: LivingEntity) -> Unit) {\n            lateinit var attacker: Player\n            lateinit var victim: LivingEntity\n            val attackerFake = FakePlayer(testPlugin)\n\n            runSync {\n                val world = checkNotNull(Bukkit.getWorld(\"world\"))\n                attackerFake.spawn(Location(world, 0.0, 100.0, 0.0))\n                val zombie = world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Zombie::class.java)\n                attacker = checkNotNull(Bukkit.getPlayer(attackerFake.uuid))\n                victim = zombie\n\n                setOldModeset(attacker)\n\n                attacker.inventory.clear()\n                attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }\n\n                attacker.isInvulnerable = false\n                victim.isInvulnerable = false\n                victim.health = victim.maxHealth\n                victim.noDamageTicks = 0\n            }\n\n            try {\n                repeat(40) {\n                    if (attacker.isOnline && attacker.isValid && victim.isValid && !victim.isDead) {\n                        if (attacker.world.players.contains(attacker) && attacker.world.entities.any { it.uniqueId == victim.uniqueId }) {\n                            return@repeat\n                        }\n                    }\n                    delayTicks(1)\n                }\n                block(attacker, victim)\n            } finally {\n                runSync {\n                    attackerFake.removePlayer()\n                    if (victim.isValid) victim.remove()\n                }\n            }\n        }\n\n        test(\"weapon durability only changes with successful hits during invulnerability\") {\n            withAttackerAndVictim { attacker, victim ->\n                appendDebug(\"invuln:start\")\n                try {\n                    val weapon = ItemStack(Material.IRON_SWORD)\n                    setItemDamage(weapon, 0)\n                    runSync {\n                        attacker.inventory.setItemInMainHand(weapon)\n                        victim.maximumNoDamageTicks = 100\n                        victim.noDamageTicks = 0\n                        attacker.gameMode = org.bukkit.GameMode.SURVIVAL\n                        val direction = victim.location.toVector().subtract(attacker.location.toVector())\n                        attacker.teleport(attacker.location.setDirection(direction))\n                    }\n                    delayTicks(5)\n                    appendDebug(\n                        \"invuln:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} \" +\n                            \"victimDead=${victim.isDead} \" +\n                            \"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} \" +\n                            \"worldPvp=${victim.world.pvp} \" +\n                            \"nms=${describeNmsState(attacker, victim)}\",\n                    )\n\n                    val hitCount = AtomicInteger(0)\n                    val totalHitCount = AtomicInteger(0)\n                    val cancelledHitCount = AtomicInteger(0)\n                    val victimEventCount = AtomicInteger(0)\n                    val anyDamageEventCount = AtomicInteger(0)\n                    val allDamageEventCount = AtomicInteger(0)\n                    val itemDamageCount = AtomicInteger(0)\n                    val listener =\n                        object : Listener {\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                            fun onHit(event: EntityDamageByEntityEvent) {\n                                if (event.entity.uniqueId == victim.uniqueId) {\n                                    victimEventCount.incrementAndGet()\n                                    if (event.damager == attacker) {\n                                        totalHitCount.incrementAndGet()\n                                        if (event.isCancelled) {\n                                            cancelledHitCount.incrementAndGet()\n                                        } else {\n                                            hitCount.incrementAndGet()\n                                        }\n                                    }\n                                }\n                            }\n\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                            fun onAnyDamage(event: EntityDamageEvent) {\n                                allDamageEventCount.incrementAndGet()\n                                if (event.entity.uniqueId == victim.uniqueId) {\n                                    anyDamageEventCount.incrementAndGet()\n                                }\n                            }\n\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                            fun onItemDamage(event: PlayerItemDamageEvent) {\n                                if (event.player == attacker && event.item.type == Material.IRON_SWORD) {\n                                    itemDamageCount.addAndGet(event.damage)\n                                }\n                            }\n                        }\n\n                    runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }\n                    try {\n                        runSync {\n                            Bukkit.getPluginManager().callEvent(\n                                EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),\n                            )\n                        }\n                        delayTicks(1)\n                        appendDebug(\"invuln:afterManualEvent allDamageEvents=${allDamageEventCount.get()}\")\n                        runSync { attackNms(attacker, victim) }\n                        delayTicks(1)\n                        repeat(10) {\n                            runSync { attackNms(attacker, victim) }\n                            delayTicks(1)\n                        }\n                        delayTicks(2)\n                    } finally {\n                        runSync { HandlerList.unregisterAll(listener) }\n                    }\n\n                    val hits = hitCount.get()\n                    val totalHits = totalHitCount.get()\n                    val cancelledHits = cancelledHitCount.get()\n                    val damageEvents = itemDamageCount.get()\n                    val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)\n\n                    if (totalHits == 0) {\n                        val beforeHealth = victim.health\n                        runSync { victim.damage(1.0, attacker) }\n                        delayTicks(1)\n                        appendDebug(\n                            \"invuln:afterDamage totalHits=${totalHitCount.get()} \" +\n                                \"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}\",\n                        )\n                    }\n\n                    appendDebug(\n                        \"invuln:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits \" +\n                            \"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} \" +\n                            \"allDamageEvents=${allDamageEventCount.get()} \" +\n                            \"itemDamageEvents=$damageEvents itemDamage=$actualDamage\",\n                    )\n\n                    if (hits <= 0) {\n                        // Retry a few swings in case legacy fake player validity lagged.\n                        repeat(5) {\n                            runSync {\n                                victim.noDamageTicks = 0\n                                attackNms(attacker, victim)\n                            }\n                            delayTicks(1)\n                        }\n                    }\n\n                    val finalHits = hitCount.get()\n                    val finalDamageEvents = itemDamageCount.get()\n                    val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)\n\n                    val isModern =\n                        kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\n                            .versionIsNewerOrEqualTo(1, 12, 0)\n                    if (!isModern) {\n                        // Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.\n                        return@withAttackerAndVictim\n                    }\n\n                    if (finalHits <= 0) {\n                        error(\"Expected at least one successful hit, got $finalHits\")\n                    }\n\n                    if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {\n                        error(\n                            \"Durability changed per click, not per hit: hits=$finalHits \" +\n                                \"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage\",\n                        )\n                    }\n                } catch (e: Throwable) {\n                    appendDebug(\"invuln:error=${e::class.java.simpleName}: ${e.message}\")\n                    throw e\n                }\n            }\n        }\n\n        test(\"weapon durability increments on hits after invulnerability expires\") {\n            withAttackerAndVictim { attacker, victim ->\n                appendDebug(\"expire:start\")\n                try {\n                    val weapon = ItemStack(Material.IRON_SWORD)\n                    setItemDamage(weapon, 0)\n                    runSync {\n                        attacker.inventory.setItemInMainHand(weapon)\n                        victim.maximumNoDamageTicks = 10\n                        victim.noDamageTicks = 0\n                        attacker.gameMode = org.bukkit.GameMode.SURVIVAL\n                        val direction = victim.location.toVector().subtract(attacker.location.toVector())\n                        attacker.teleport(attacker.location.setDirection(direction))\n                    }\n                    delayTicks(5)\n                    appendDebug(\n                        \"expire:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} \" +\n                            \"victimDead=${victim.isDead} \" +\n                            \"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} \" +\n                            \"worldPvp=${victim.world.pvp} \" +\n                            \"nms=${describeNmsState(attacker, victim)}\",\n                    )\n\n                    val hitCount = AtomicInteger(0)\n                    val totalHitCount = AtomicInteger(0)\n                    val cancelledHitCount = AtomicInteger(0)\n                    val victimEventCount = AtomicInteger(0)\n                    val anyDamageEventCount = AtomicInteger(0)\n                    val allDamageEventCount = AtomicInteger(0)\n                    val itemDamageCount = AtomicInteger(0)\n                    val listener =\n                        object : Listener {\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                            fun onHit(event: EntityDamageByEntityEvent) {\n                                if (event.entity.uniqueId == victim.uniqueId) {\n                                    victimEventCount.incrementAndGet()\n                                    if (event.damager == attacker) {\n                                        totalHitCount.incrementAndGet()\n                                        if (event.isCancelled) {\n                                            cancelledHitCount.incrementAndGet()\n                                        } else {\n                                            hitCount.incrementAndGet()\n                                        }\n                                    }\n                                }\n                            }\n\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)\n                            fun onAnyDamage(event: EntityDamageEvent) {\n                                allDamageEventCount.incrementAndGet()\n                                if (event.entity.uniqueId == victim.uniqueId) {\n                                    anyDamageEventCount.incrementAndGet()\n                                }\n                            }\n\n                            @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n                            fun onItemDamage(event: PlayerItemDamageEvent) {\n                                if (event.player == attacker && event.item.type == Material.IRON_SWORD) {\n                                    itemDamageCount.addAndGet(event.damage)\n                                }\n                            }\n                        }\n\n                    runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }\n                    try {\n                        runSync {\n                            Bukkit.getPluginManager().callEvent(\n                                EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),\n                            )\n                        }\n                        delayTicks(1)\n                        appendDebug(\"expire:afterManualEvent allDamageEvents=${allDamageEventCount.get()}\")\n                        runSync { attackNms(attacker, victim) }\n                        delayTicks(12)\n                        runSync { attackNms(attacker, victim) }\n                        delayTicks(2)\n                    } finally {\n                        runSync { HandlerList.unregisterAll(listener) }\n                    }\n\n                    val hits = hitCount.get()\n                    val totalHits = totalHitCount.get()\n                    val cancelledHits = cancelledHitCount.get()\n                    val damageEvents = itemDamageCount.get()\n                    val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)\n\n                    if (totalHits == 0) {\n                        val beforeHealth = victim.health\n                        runSync { victim.damage(1.0, attacker) }\n                        delayTicks(1)\n                        appendDebug(\n                            \"expire:afterDamage totalHits=${totalHitCount.get()} \" +\n                                \"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}\",\n                        )\n                    }\n\n                    appendDebug(\n                        \"expire:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits \" +\n                            \"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} \" +\n                            \"allDamageEvents=${allDamageEventCount.get()} \" +\n                            \"itemDamageEvents=$damageEvents itemDamage=$actualDamage\",\n                    )\n\n                    if (hits < 2) {\n                        repeat(4) {\n                            runSync { attackNms(attacker, victim) }\n                            delayTicks(2)\n                        }\n                    }\n\n                    val finalHits = hitCount.get()\n                    val finalDamageEvents = itemDamageCount.get()\n                    val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)\n\n                    val isModern =\n                        kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\n                            .versionIsNewerOrEqualTo(1, 12, 0)\n                    if (!isModern) {\n                        // Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.\n                        return@withAttackerAndVictim\n                    }\n\n                    val expectedHits = 2\n\n                    if (finalHits < expectedHits) {\n                        error(\"Expected at least $expectedHits hits after invulnerability expiry, got $finalHits\")\n                    }\n\n                    if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {\n                        error(\n                            \"Durability did not match hits: hits=$finalHits \" +\n                                \"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage\",\n                        )\n                    }\n                } catch (e: Throwable) {\n                    appendDebug(\"expire:error=${e::class.java.simpleName}: ${e.message}\")\n                    throw e\n                }\n            }\n        }\n    })\n"
  },
  {
    "path": "src/integrationTest/resources/plugin.yml",
    "content": "# This Source Code Form is subject to the terms of the Mozilla Public\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at https://mozilla.org/MPL/2.0/.\nmain: kernitus.plugin.OldCombatMechanics.OCMTestMain\nname: OldCombatMechanicsTest\nversion: 0.0.1\nauthors: [ kernitus, Rayzr522 ]\ndescription: Reverts to pre-1.9 combat mechanics\nwebsite: https://github.com/kernitus/BukkitOldCombatMechanics\nload: POSTWORLD\nsoftdepend: [ OldCombatMechanics ]\napi-version: 1.13\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/ModuleLoader.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics;\n\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport kernitus.plugin.OldCombatMechanics.utilities.EventRegistry;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ModuleLoader {\n\n    private static EventRegistry eventRegistry;\n    private static final List<OCMModule> modules = new ArrayList<>();\n\n    public static void initialise(OCMMain plugin) {\n        modules.clear();\n        ModuleLoader.eventRegistry = new EventRegistry(plugin);\n    }\n\n    public static void toggleModules() {\n        modules.forEach(module -> setState(module, module.isEnabled()));\n    }\n\n    private static void setState(OCMModule module, boolean state) {\n        if (state) {\n            if (eventRegistry.registerListener(module)) {\n                Messenger.debug(\"Enabled \" + module.getClass().getSimpleName());\n            }\n        } else {\n            if (eventRegistry.unregisterListener(module)) {\n                Messenger.debug(\"Disabled \" + module.getClass().getSimpleName());\n            }\n        }\n    }\n\n    public static void addModule(OCMModule module) {\n        modules.add(module);\n    }\n\n    public static List<OCMModule> getModules() {\n        return modules;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/OCMConfigHandler.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics;\n\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport org.bukkit.configuration.ConfigurationSection;\nimport org.bukkit.configuration.file.YamlConfiguration;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.HashSet;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class OCMConfigHandler {\n    private final String CONFIG_NAME = \"config.yml\";\n    private final OCMMain plugin;\n\n    public OCMConfigHandler(OCMMain instance) {\n        this.plugin = instance;\n    }\n\n    public void upgradeConfig() {\n        // Remove old backup file if present\n        final File backup = getFile(\"config-backup.yml\");\n        if (backup.exists()) backup.delete();\n\n        // Keeping YAML comments not available in lower versions\n        if (Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||\n                Config.getConfig().getBoolean(\"force-below-1-18-1-config-upgrade\", false)\n        ) {\n            plugin.getLogger().warning(\"Config version does not match, upgrading old config\");\n\n            final File configFile = getFile(CONFIG_NAME);\n\n            // Back up the old config file\n            if (!configFile.renameTo(backup)) {\n                plugin.getLogger().severe(\"Could not back up old config file. Aborting config upgrade.\");\n                return;\n            }\n\n            // Save the new default config from the JAR to config.yml. This ensures all old keys are gone.\n            plugin.saveResource(CONFIG_NAME, true);\n\n            // Now, load the old values from the backup and the new config from the fresh file\n            final YamlConfiguration oldConfig = YamlConfiguration.loadConfiguration(backup);\n            final YamlConfiguration newConfig = YamlConfiguration.loadConfiguration(configFile);\n\n            // Copy user's values for keys that still exist\n            for (String key : newConfig.getKeys(true)) {\n                if (key.equals(\"config-version\")) continue;\n                if (newConfig.isConfigurationSection(key)) continue;\n\n                if (oldConfig.contains(key) && !oldConfig.isConfigurationSection(key)) {\n                    newConfig.set(key, oldConfig.get(key));\n                }\n            }\n\n            migrateModuleLists(oldConfig, newConfig);\n\n            // Save the final, merged config\n            try {\n                newConfig.save(configFile);\n                plugin.getLogger().info(\"Config has been updated. A backup of your old config is available at config-backup.yml\");\n            } catch (IOException e) {\n                plugin.getLogger().severe(\"Failed to save upgraded config. It has been restored from backup.\");\n                e.printStackTrace();\n                backup.renameTo(configFile); // Restore backup\n            }\n        } else {\n            plugin.getLogger().warning(\"Config version does not match, backing up old config and creating a new one\");\n            // Change name of old config\n            final File configFile = getFile(CONFIG_NAME);\n            configFile.renameTo(backup);\n        }\n\n        // Save new version if none is present\n        setupConfigIfNotPresent();\n    }\n\n    /**\n     * Generates new config.yml file, if not present.\n     */\n    public void setupConfigIfNotPresent() {\n        if (!doesConfigExist()) {\n            plugin.saveDefaultConfig();\n            plugin.getLogger().info(\"Config file generated\");\n        }\n    }\n\n    private void migrateModuleLists(YamlConfiguration oldConfig, YamlConfiguration newConfig) {\n        final Set<String> internalModules = new HashSet<>(Arrays.asList(\n                \"modeset-listener\",\n                \"attack-cooldown-tracker\",\n                \"entity-damage-listener\"\n        ));\n        final Set<String> optionalModules = new HashSet<>(Arrays.asList(\n                \"disable-attack-sounds\",\n                \"disable-sword-sweep-particles\"\n        ));\n        final Set<String> moduleNames = ModuleLoader.getModules().stream()\n                .map(module -> module.getConfigName().toLowerCase(Locale.ROOT))\n                .collect(Collectors.toCollection(LinkedHashSet::new));\n\n        final ConfigurationSection oldModesets = oldConfig.getConfigurationSection(\"modesets\");\n        final Map<String, List<String>> migratedModesets = new LinkedHashMap<>();\n        final Set<String> modulesInModesets = new HashSet<>();\n\n        if (oldModesets != null) {\n            for (String modesetName : oldModesets.getKeys(false)) {\n                final List<String> moduleList = oldModesets.getStringList(modesetName);\n                final List<String> normalisedList = moduleList.stream()\n                        .map(name -> name.toLowerCase(Locale.ROOT))\n                        .collect(Collectors.toList());\n                normalisedList.removeIf(internalModules::contains);\n                migratedModesets.put(modesetName, normalisedList);\n                modulesInModesets.addAll(normalisedList);\n            }\n        }\n\n        moduleNames.addAll(optionalModules);\n\n        if (moduleNames.isEmpty()) {\n            for (String key : oldConfig.getKeys(true)) {\n                if (!key.endsWith(\".enabled\")) {\n                    continue;\n                }\n                final String moduleName = key.substring(0, key.length() - \".enabled\".length())\n                        .toLowerCase(Locale.ROOT);\n                if (internalModules.contains(moduleName)) {\n                    continue;\n                }\n                moduleNames.add(moduleName);\n            }\n            moduleNames.addAll(modulesInModesets);\n        }\n\n        moduleNames.removeAll(internalModules);\n\n        final List<String> alwaysEnabled = new ArrayList<>();\n        final List<String> disabledModules = new ArrayList<>();\n\n        for (String moduleName : moduleNames) {\n            final String enabledKey = moduleName + \".enabled\";\n            if (\"attack-range\".equals(moduleName)) {\n                disabledModules.add(moduleName);\n                continue;\n            }\n            final boolean enabled = !oldConfig.contains(enabledKey) || oldConfig.getBoolean(enabledKey);\n\n            if (!enabled) {\n                disabledModules.add(moduleName);\n                continue;\n            }\n\n            if (!modulesInModesets.contains(moduleName)) {\n                alwaysEnabled.add(moduleName);\n            }\n        }\n\n        // Remove disabled modules from all modesets\n        if (!disabledModules.isEmpty()) {\n            for (Map.Entry<String, List<String>> entry : migratedModesets.entrySet()) {\n                entry.getValue().removeIf(disabledModules::contains);\n            }\n        }\n\n        newConfig.set(\"always_enabled_modules\", alwaysEnabled);\n        newConfig.set(\"disabled_modules\", disabledModules);\n\n        if (!migratedModesets.isEmpty()) {\n            ConfigurationSection targetModesets = newConfig.getConfigurationSection(\"modesets\");\n            if (targetModesets == null) {\n                targetModesets = newConfig.createSection(\"modesets\");\n            }\n            // Remove old keys that are no longer present, without deleting the section (keeps placement)\n            for (String key : new ArrayList<>(targetModesets.getKeys(false))) {\n                if (!migratedModesets.containsKey(key)) {\n                    targetModesets.set(key, null);\n                }\n            }\n            // Apply migrated entries\n            for (Map.Entry<String, List<String>> entry : migratedModesets.entrySet()) {\n                targetModesets.set(entry.getKey(), entry.getValue());\n            }\n        }\n\n        final ConfigurationSection oldWorlds = oldConfig.getConfigurationSection(\"worlds\");\n        if (oldWorlds != null) {\n            for (String worldName : oldWorlds.getKeys(false)) {\n                newConfig.set(\"worlds.\" + worldName, oldWorlds.getStringList(worldName));\n            }\n        }\n    }\n\n    public YamlConfiguration getConfig(String fileName) {\n        return YamlConfiguration.loadConfiguration(getFile(fileName));\n    }\n\n    public File getFile(String fileName) {\n        return new File(plugin.getDataFolder(), fileName.replace('/', File.separatorChar));\n    }\n\n    public boolean doesConfigExist() {\n        return getFile(CONFIG_NAME).exists();\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/OCMMain.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics;\n\nimport com.github.retrooper.packetevents.PacketEvents;\nimport io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;\nimport kernitus.plugin.OldCombatMechanics.commands.OCMCommandCompleter;\nimport kernitus.plugin.OldCombatMechanics.commands.OCMCommandHandler;\nimport kernitus.plugin.OldCombatMechanics.hooks.PlaceholderAPIHook;\nimport kernitus.plugin.OldCombatMechanics.hooks.api.Hook;\nimport kernitus.plugin.OldCombatMechanics.module.*;\nimport kernitus.plugin.OldCombatMechanics.updater.ModuleUpdateChecker;\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.EntityDamageByEntityListener;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.ModesetListener;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;\nimport org.bstats.bukkit.Metrics;\nimport org.bstats.charts.SimpleBarChart;\nimport org.bstats.charts.SimplePie;\nimport org.bukkit.Bukkit;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.event.EventException;\nimport org.bukkit.event.player.PlayerJoinEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.plugin.PluginDescriptionFile;\nimport org.bukkit.plugin.RegisteredListener;\nimport org.bukkit.plugin.java.JavaPlugin;\nimport org.jetbrains.annotations.NotNull;\n\nimport java.io.File;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class OCMMain extends JavaPlugin {\n\n    private static OCMMain INSTANCE;\n    private final Logger logger = getLogger();\n    private final OCMConfigHandler CH = new OCMConfigHandler(this);\n    private final List<Runnable> disableListeners = new ArrayList<>();\n    private final List<Runnable> enableListeners = new ArrayList<>();\n    private final List<Hook> hooks = new ArrayList<>();\n    public OCMMain() {\n        super();\n    }\n\n    @Override\n    public void onLoad() {\n        PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this));\n        PacketEvents.getAPI().load();\n    }\n\n    @Override\n    public void onEnable() {\n        INSTANCE = this;\n\n        // Setting up config.yml\n        CH.setupConfigIfNotPresent();\n\n        // Initialise persistent player storage\n        PlayerStorage.initialise(this);\n\n        // Initialise ModuleLoader utility\n        ModuleLoader.initialise(this);\n\n        // Initialise Config utility\n        Config.initialise(this);\n\n        // Initialise the Messenger utility\n        Messenger.initialise(this);\n        PacketEvents.getAPI().init();\n\n        // Register all the modules\n        registerModules();\n\n        // Register all hooks for integrating with other plugins\n        registerHooks();\n\n        // Initialise all the hooks\n        hooks.forEach(hook -> hook.init(this));\n\n        // Set up the command handler\n        getCommand(\"OldCombatMechanics\").setExecutor(new OCMCommandHandler(this));\n        // Set up command tab completer\n        getCommand(\"OldCombatMechanics\").setTabCompleter(new OCMCommandCompleter());\n\n        Config.reload();\n\n        // BStats Metrics\n        final Metrics metrics = new Metrics(this, 53);\n\n        metrics.addCustomChart(new SimplePie(\"server_software\", () -> {\n            final String name = Bukkit.getServer().getName();\n            if (name == null || name.isEmpty()) return \"Unknown\";\n            final String cleaned = name.split(\"\\\\s\", 2)[0].trim();\n            return cleaned.isEmpty() ? \"Unknown\" : cleaned;\n        }));\n\n        // Simple bar chart (kept in case bStats re-enables bar display)\n        metrics.addCustomChart(\n                new SimpleBarChart(\n                        \"enabled_modules\",\n                        () -> ModuleLoader.getModules().stream()\n                                .filter(OCMModule::isEnabled)\n                                .collect(Collectors.toMap(OCMModule::toString, module -> 1))));\n\n        // Pie chart of enabled/disabled for each module\n        ModuleLoader.getModules().forEach(module -> metrics.addCustomChart(\n                new SimplePie(module.getModuleName() + \"_pie\",\n                        () -> module.isEnabled() ? \"enabled\" : \"disabled\")));\n\n        // Simple pie: exact count of enabled modules per server (as a string key).\n        metrics.addCustomChart(new SimplePie(\"enabled_modules_count\", () -> {\n            int count = (int) ModuleLoader.getModules().stream().filter(OCMModule::isEnabled).count();\n            return Integer.toString(count);\n        }));\n\n        enableListeners.forEach(Runnable::run);\n\n        // Properly handle Plugman load/unload.\n        final List<RegisteredListener> joinListeners = Arrays\n                .stream(PlayerJoinEvent.getHandlerList().getRegisteredListeners())\n                .filter(registeredListener -> registeredListener.getPlugin().equals(this))\n                .collect(Collectors.toList());\n\n        Bukkit.getOnlinePlayers().forEach(player -> {\n            final PlayerJoinEvent event = new PlayerJoinEvent(player, \"\");\n\n            // Trick all the modules into thinking the player just joined in case the plugin\n            // was loaded with Plugman.\n            // This way attack speeds, item modifications, etc. will be applied immediately\n            // instead of after a re-log.\n            joinListeners.forEach(registeredListener -> {\n                try {\n                    registeredListener.callEvent(event);\n                } catch (EventException e) {\n                    e.printStackTrace();\n                }\n            });\n        });\n\n        // Logging to console the enabling of OCM\n        final PluginDescriptionFile pdfFile = this.getDescription();\n        logger.info(pdfFile.getName() + \" v\" + pdfFile.getVersion() + \" has been enabled\");\n\n        if (Config.moduleEnabled(\"update-checker\"))\n            Bukkit.getScheduler().runTaskLaterAsynchronously(this,\n                    () -> new UpdateChecker(this).performUpdate(), 20L);\n\n        metrics.addCustomChart(new SimplePie(\"auto_update_pie\",\n                () -> Config.moduleSettingEnabled(\"update-checker\",\n                        \"auto-update\") ? \"enabled\" : \"disabled\"));\n\n    }\n\n    @Override\n    public void onDisable() {\n        final PluginDescriptionFile pdfFile = this.getDescription();\n\n        disableListeners.forEach(Runnable::run);\n\n        // Properly handle Plugman load/unload.\n        final List<RegisteredListener> quitListeners = Arrays\n                .stream(PlayerQuitEvent.getHandlerList().getRegisteredListeners())\n                .filter(registeredListener -> registeredListener.getPlugin().equals(this))\n                .collect(Collectors.toList());\n\n        // Trick all the modules into thinking the player just quit in case the plugin\n        // was unloaded with Plugman.\n        // This way attack speeds, item modifications, etc. will be restored immediately\n        // instead of after a disconnect.\n        Bukkit.getOnlinePlayers().forEach(player -> {\n            final PlayerQuitEvent event = new PlayerQuitEvent(player, \"\");\n\n            quitListeners.forEach(registeredListener -> {\n                try {\n                    registeredListener.callEvent(event);\n                } catch (EventException e) {\n                    e.printStackTrace();\n                }\n            });\n        });\n\n        PlayerStorage.instantSave();\n\n        PacketEvents.getAPI().terminate();\n\n        // Logging to console the disabling of OCM\n        logger.info(pdfFile.getName() + \" v\" + pdfFile.getVersion() + \" has been disabled\");\n    }\n\n    private void registerModules() {\n        // Update Checker (also a module, so we can use the dynamic\n        // registering/unregistering)\n        ModuleLoader.addModule(new ModuleUpdateChecker(this));\n\n        // Modeset listener, for when player joins or changes world\n        ModuleLoader.addModule(new ModesetListener(this));\n\n        // Module listeners\n        ModuleLoader.addModule(new ModuleAttackCooldown(this));\n        ModuleLoader.addModule(new ModuleAttackRange(this));\n\n        // If below 1.16, we need to keep track of player attack cooldown ourselves\n        if (Reflector.getMethod(HumanEntity.class, \"getAttackCooldown\", 0) == null) {\n            ModuleLoader.addModule(new AttackCooldownTracker(this));\n        }\n\n        // Listeners registered later with same priority are called later\n\n        // These four listen to OCMEntityDamageByEntityEvent:\n        ModuleLoader.addModule(new ModuleOldToolDamage(this));\n        ModuleLoader.addModule(new ModuleSwordSweep(this));\n        ModuleLoader.addModule(new ModuleOldPotionEffects(this));\n        ModuleLoader.addModule(new ModuleOldCriticalHits(this));\n\n        // Next block are all on LOWEST priority, so will be called in the following\n        // order:\n        // Damage order: base -> potion effects -> critical hit -> enchantments\n        // Defence order: overdamage -> blocking -> armour -> resistance -> armour enchs\n        // -> absorption\n        // EntityDamageByEntityListener calls OCMEntityDamageByEntityEvent, see modules\n        // above\n        // For everything from base to overdamage\n        ModuleLoader.addModule(new EntityDamageByEntityListener(this));\n        // ModuleSwordBlocking to calculate blocking\n        ModuleLoader.addModule(new ModuleShieldDamageReduction(this));\n        // OldArmourStrength for armour -> resistance -> armour enchs -> absorption\n        ModuleLoader.addModule(new ModuleOldArmourStrength(this));\n\n        ModuleLoader.addModule(new ModuleSwordBlocking(this));\n        ModuleLoader.addModule(new ModuleOldArmourDurability(this));\n\n        ModuleLoader.addModule(new ModuleGoldenApple(this));\n        ModuleLoader.addModule(new ModuleFishingKnockback(this));\n        ModuleLoader.addModule(new ModulePlayerKnockback(this));\n        ModuleLoader.addModule(new ModulePlayerRegen(this));\n\n        ModuleLoader.addModule(new ModuleDisableCrafting(this));\n        ModuleLoader.addModule(new ModuleDisableOffHand(this));\n        ModuleLoader.addModule(new ModuleOldBrewingStand(this));\n        ModuleLoader.addModule(new ModuleProjectileKnockback(this));\n        ModuleLoader.addModule(new ModuleDisableEnderpearlCooldown(this));\n        ModuleLoader.addModule(new ModuleChorusFruit(this));\n\n        ModuleLoader.addModule(new ModuleOldBurnDelay(this));\n        ModuleLoader.addModule(new ModuleAttackFrequency(this));\n        ModuleLoader.addModule(new ModuleFishingRodVelocity(this));\n\n        ModuleLoader.addModule(new ModuleAttackSounds(this));\n        ModuleLoader.addModule(new ModuleSwordSweepParticles(this));\n    }\n\n    private void registerHooks() {\n        if (getServer().getPluginManager().isPluginEnabled(\"PlaceholderAPI\"))\n            hooks.add(new PlaceholderAPIHook());\n    }\n\n    public void upgradeConfig() {\n        CH.upgradeConfig();\n    }\n\n    public boolean doesConfigExist() {\n        return CH.doesConfigExist();\n    }\n\n    /**\n     * Registers a runnable to run when the plugin gets disabled.\n     *\n     * @param action the {@link Runnable} to run when the plugin gets disabled\n     */\n    public void addDisableListener(Runnable action) {\n        disableListeners.add(action);\n    }\n\n    /**\n     * Registers a runnable to run when the plugin gets enabled.\n     *\n     * @param action the {@link Runnable} to run when the plugin gets enabled\n     */\n    public void addEnableListener(Runnable action) {\n        enableListeners.add(action);\n    }\n\n    /**\n     * Get the plugin's JAR file\n     *\n     * @return The File object corresponding to this plugin\n     */\n    @NotNull\n    @Override\n    public File getFile() {\n        return super.getFile();\n    }\n\n    public static OCMMain getInstance() {\n        return INSTANCE;\n    }\n\n    public static String getVersion() {\n        return INSTANCE.getDescription().getVersion();\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/UpdateChecker.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics;\n\nimport kernitus.plugin.OldCombatMechanics.updater.SpigetUpdateChecker;\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport org.bukkit.ChatColor;\nimport org.bukkit.entity.Player;\n\nimport javax.annotation.Nullable;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.function.Consumer;\n\npublic class UpdateChecker {\n    private final SpigetUpdateChecker updater;\n    private final boolean autoDownload;\n    private final OCMMain plugin;\n\n    public UpdateChecker(OCMMain plugin) {\n        updater = new SpigetUpdateChecker();\n        this.plugin = plugin;\n        // We don't really want to auto update if the config is not going to be upgraded automatically\n        autoDownload = Config.moduleSettingEnabled(\"update-checker\", \"auto-update\") &&\n                (Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||\n                        Config.getConfig().getBoolean(\"force-below-1-18-1-config-upgrade\", false)\n                );\n    }\n\n\n    public void performUpdate() {\n        performUpdate(null);\n    }\n\n    public void performUpdate(@Nullable Player player) {\n        if (player != null)\n            update(player::sendMessage);\n        else\n            update(Messenger::info);\n    }\n\n    private void update(Consumer<String> target) {\n        final List<String> messages = new ArrayList<>();\n        if (updater.isUpdateAvailable()) {\n            messages.add(ChatColor.BLUE + \"An update for OldCombatMechanics to version \" + updater.getLatestVersion() + \" is available!\");\n            if (!autoDownload) {\n                messages.add(ChatColor.BLUE + \"Click here to download it: \" + ChatColor.GRAY + updater.getUpdateURL());\n            } else {\n                messages.add(ChatColor.BLUE + \"Downloading update: \" + ChatColor.GRAY + updater.getUpdateURL());\n                try {\n                    if (updater.downloadLatestVersion(plugin.getServer().getUpdateFolderFile(), plugin.getFile().getName()))\n                        messages.add(ChatColor.GREEN + \"Update downloaded. Restart or reload server to enable new version.\");\n                    else throw new RuntimeException();\n                } catch (Exception e) {\n                    messages.add(ChatColor.RED + \"Error occurred while downloading update! Check console for more details\");\n                    e.printStackTrace();\n                }\n            }\n        }\n\n        messages.forEach(target);\n    }\n}"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/commands/OCMCommandCompleter.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.commands;\n\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport org.bukkit.Bukkit;\nimport org.bukkit.World;\nimport org.bukkit.command.Command;\nimport org.bukkit.command.CommandSender;\nimport org.bukkit.command.TabCompleter;\nimport org.bukkit.entity.Player;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nimport static kernitus.plugin.OldCombatMechanics.commands.OCMCommandHandler.Subcommand;\n\n/**\n * Provides tab completion for OCM commands\n */\npublic class OCMCommandCompleter implements TabCompleter {\n\n    @Nullable\n    @Override\n    public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {\n        final List<String> completions = new ArrayList<>();\n\n        if (args.length < 2) {\n            completions.addAll(Arrays.stream(Subcommand.values())\n                    .filter(arg -> arg.toString().startsWith(args[0]))\n                    .filter(arg -> OCMCommandHandler.checkPermissions(sender, arg))\n                    .map(Enum::toString).collect(Collectors.toList()));\n        } else if (args[0].equalsIgnoreCase(Subcommand.mode.toString())) {\n            if (args.length < 3) {\n                if (sender.hasPermission(\"oldcombatmechanics.mode.others\")\n                        || sender.hasPermission(\"oldcombatmechanics.mode.own\")\n                ) {\n                    if (sender instanceof Player) { // Get the modesets allowed in the world player is in\n                        final World world = ((Player) sender).getWorld();\n                        completions.addAll(\n                                Config.getWorlds()\n                                        // If world not in config, all modesets allowed\n                                        .getOrDefault(world.getUID(), Config.getModesets().keySet())\n                                        .stream()\n                                        .filter(ms -> ms.startsWith(args[1]))\n                                        .collect(Collectors.toList()));\n                    } else {\n                        completions.addAll(Config.getModesets().keySet().stream()\n                                .filter(ms -> ms.startsWith(args[1]))\n                                .collect(Collectors.toList()));\n                    }\n                }\n            } else if (sender.hasPermission(\"oldcombatmechanics.mode.others\")) {\n                completions.addAll(Bukkit.getOnlinePlayers().stream()\n                        .map(Player::getName)\n                        .filter(arg -> arg.startsWith(args[2]))\n                        .collect(Collectors.toList()));\n            }\n\n        }\n\n        return completions;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/commands/OCMCommandHandler.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.commands;\n\nimport kernitus.plugin.OldCombatMechanics.ModuleLoader;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerData;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;\nimport org.bukkit.Bukkit;\nimport org.bukkit.ChatColor;\nimport org.bukkit.command.Command;\nimport org.bukkit.command.CommandExecutor;\nimport org.bukkit.command.CommandSender;\nimport org.bukkit.entity.Player;\nimport org.bukkit.plugin.PluginDescriptionFile;\nimport org.jetbrains.annotations.NotNull;\n\nimport java.util.Locale;\nimport java.util.Set;\nimport java.util.UUID;\n\npublic class OCMCommandHandler implements CommandExecutor {\n    private static final String NO_PERMISSION = \"&cYou need the permission '%s' to do that!\";\n\n    private final OCMMain plugin;\n\n    enum Subcommand {\n        reload, mode\n    }\n\n    public OCMCommandHandler(OCMMain instance) {\n        this.plugin = instance;\n    }\n\n    private void help(OCMMain plugin, CommandSender sender) {\n        final PluginDescriptionFile description = plugin.getDescription();\n\n        Messenger.sendNoPrefix(sender, ChatColor.DARK_GRAY + Messenger.HORIZONTAL_BAR);\n        Messenger.sendNoPrefix(sender, \"&6&lOldCombatMechanics&e by &ckernitus&e and &cRayzr522&e version &6%s\",\n                description.getVersion());\n\n        if (checkPermissions(sender, Subcommand.reload))\n            Messenger.sendNoPrefix(sender, \"&eYou can use &c/ocm reload&e to reload the config file\");\n        if (checkPermissions(sender, Subcommand.mode))\n            Messenger.sendNoPrefix(sender,\n                    Config.getConfig().getString(\"mode-messages.message-usage\",\n                            \"&4ERROR: &rmode-messages.message-usage string missing\"));\n\n        Messenger.sendNoPrefix(sender, ChatColor.DARK_GRAY + Messenger.HORIZONTAL_BAR);\n    }\n\n    private void reload(CommandSender sender) {\n        Config.reload();\n        Messenger.sendNoPrefix(sender, \"&6&lOldCombatMechanics&e config file reloaded\");\n    }\n\n    private void mode(CommandSender sender, String[] args) {\n        // If just /ocm mode\n        if (args.length < 2) {\n            if (sender instanceof Player) {\n                final Player player = ((Player) sender);\n                final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());\n                String modeName = playerData.getModesetForWorld(player.getWorld().getUID());\n                if (modeName == null || modeName.isEmpty())\n                    modeName = \"unknown\";\n\n                Messenger.send(sender,\n                        Config.getConfig().getString(\"mode-messages.mode-status\",\n                                \"&4ERROR: &rmode-messages.mode-status string missing\"),\n                        modeName);\n            }\n            Messenger.send(sender,\n                    Config.getConfig().getString(\"mode-messages.message-usage\",\n                            \"&4ERROR: &rmode-messages.message-usage string missing\"));\n            return;\n        }\n\n        final String modesetName = args[1].toLowerCase(Locale.ROOT);\n\n        if (!Config.getModesets().containsKey(modesetName)) {\n            Messenger.send(sender,\n                    Config.getConfig().getString(\"mode-messages.invalid-modeset\",\n                            \"&4ERROR: &rmode-messages.invalid-modeset string missing\"));\n            return;\n        }\n\n        Player player = null;\n        // If /ocm mode <mode>\n        if (args.length < 3) {\n            if (sender instanceof Player) {\n                if (!sender.hasPermission(\"oldcombatmechanics.mode.own\")) {\n                    Messenger.sendNoPrefix(sender, NO_PERMISSION, \"oldcombatmechanics.mode.own\");\n                    return;\n                }\n                player = (Player) sender;\n            } else {\n                Messenger.send(sender,\n                        Config.getConfig().getString(\"mode-messages.invalid-player\",\n                                \"&4ERROR: &rmode-messages.invalid-player string missing\"));\n                return;\n            }\n        } else { // If /ocm mode <mode> <player>\n            if (!sender.hasPermission(\"oldcombatmechanics.mode.others\")) {\n                Messenger.sendNoPrefix(sender, NO_PERMISSION, \"oldcombatmechanics.mode.others\");\n                return;\n            }\n            player = Bukkit.getPlayer(args[2]);\n        }\n\n        if (player == null) {\n            Messenger.send(sender,\n                    Config.getConfig().getString(\"mode-messages.invalid-player\",\n                            \"&4ERROR: &rmode-messages.invalid-player string missing\"));\n            return;\n        }\n\n        final UUID worldId = player.getWorld().getUID();\n        final Set<String> worldModesets = Config.getWorlds().get(worldId);\n\n        // If modesets null or empty it means not configured, so all are allowed\n        if (worldModesets != null && !worldModesets.isEmpty() && !worldModesets.contains(modesetName)) {\n            // Modeset not allowed in current world\n            Messenger.send(sender,\n                    Config.getConfig().getString(\"mode-messages.invalid-modeset\",\n                            \"&4ERROR: &rmode-messages.invalid-modeset string missing\"));\n            return;\n        }\n\n        final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());\n        playerData.setModesetForWorld(worldId, modesetName);\n        PlayerStorage.setPlayerData(player.getUniqueId(), playerData);\n        PlayerStorage.scheduleSave();\n\n        Messenger.send(sender,\n                Config.getConfig().getString(\"mode-messages.mode-set\",\n                        \"&4ERROR: &rmode-messages.mode-set string missing\"),\n                modesetName);\n\n        // Re-apply things like attack speed and collision team\n        final Player playerCopy = player;\n        ModuleLoader.getModules().forEach(module -> module.onModesetChange(playerCopy));\n    }\n\n    public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label,\n            String[] args) {\n        if (args.length < 1) {\n            help(plugin, sender);\n        } else {\n            try {\n                try {\n                    final Subcommand subcommand = Subcommand.valueOf(args[0].toLowerCase(Locale.ROOT));\n                    if (checkPermissions(sender, subcommand, true)) {\n                        switch (subcommand) {\n                            case reload:\n                                reload(sender);\n                                break;\n                            case mode:\n                                mode(sender, args);\n                                break;\n                            default:\n                                throw new CommandNotRecognisedException();\n                        }\n                    }\n                } catch (IllegalArgumentException e) {\n                    throw new CommandNotRecognisedException();\n                }\n            } catch (CommandNotRecognisedException e) {\n                Messenger.send(sender, \"Subcommand not recognised!\");\n            }\n        }\n        return true;\n    }\n\n    private static class CommandNotRecognisedException extends IllegalArgumentException {\n    }\n\n    static boolean checkPermissions(CommandSender sender, Subcommand subcommand) {\n        return checkPermissions(sender, subcommand, false);\n    }\n\n    static boolean checkPermissions(CommandSender sender, Subcommand subcommand, boolean sendMessage) {\n        final boolean hasPermission = sender.hasPermission(\"oldcombatmechanics.\" + subcommand);\n        if (sendMessage && !hasPermission)\n            Messenger.sendNoPrefix(sender, NO_PERMISSION, \"oldcombatmechanics.\" + subcommand);\n        return hasPermission;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/hooks/PlaceholderAPIHook.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.hooks;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.hooks.api.Hook;\nimport kernitus.plugin.OldCombatMechanics.module.ModuleDisableEnderpearlCooldown;\nimport kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerData;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;\nimport me.clip.placeholderapi.expansion.PlaceholderExpansion;\nimport org.bukkit.entity.Player;\nimport org.jetbrains.annotations.NotNull;\n\npublic class PlaceholderAPIHook implements Hook {\n    private PlaceholderExpansion expansion;\n\n    @Override\n    public void init(OCMMain plugin) {\n        expansion = new PlaceholderExpansion() {\n            @Override\n            public boolean canRegister() {\n                return true;\n            }\n\n            @Override\n            public boolean persist() {\n                return true;\n            }\n\n            @Override\n            public @NotNull String getIdentifier() {\n                return \"ocm\";\n            }\n\n            @Override\n            public @NotNull String getAuthor() {\n                return String.join(\", \", plugin.getDescription().getAuthors());\n            }\n\n            @Override\n            public @NotNull String getVersion() {\n                return plugin.getDescription().getVersion();\n            }\n\n            @Override\n            public String onPlaceholderRequest(Player player, @NotNull String identifier) {\n                if (player == null) return null;\n\n                switch (identifier) {\n                    case \"modeset\":\n                        return getModeset(player);\n                    case \"gapple_cooldown\":\n                        return getGappleCooldown(player);\n                    case \"napple_cooldown\":\n                        return getNappleCooldown(player);\n                    case \"enderpearl_cooldown\":\n                        return getEnderpearlCooldown(player);\n                }\n\n                return null;\n            }\n\n            private String getGappleCooldown(Player player) {\n                final long seconds = ModuleGoldenApple.getInstance().getGappleCooldown(player.getUniqueId());\n                return seconds > 0 ? String.valueOf(seconds) : \"None\";\n            }\n\n            private String getNappleCooldown(Player player) {\n                final long seconds = ModuleGoldenApple.getInstance().getNappleCooldown(player.getUniqueId());\n                return seconds > 0 ? String.valueOf(seconds) : \"None\";\n            }\n\n            private String getEnderpearlCooldown(Player player) {\n                final long seconds = ModuleDisableEnderpearlCooldown.getInstance().getEnderpearlCooldown(player.getUniqueId());\n                return seconds > 0 ? String.valueOf(seconds) : \"None\";\n            }\n\n            private String getModeset(Player player) {\n                final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());\n                String modeName = playerData.getModesetForWorld(player.getWorld().getUID());\n                if (modeName == null || modeName.isEmpty()) modeName = \"unknown\";\n                return modeName;\n            }\n        };\n\n        expansion.register();\n    }\n\n    @Override\n    public void deinit(OCMMain plugin) {\n        if (expansion != null) {\n            expansion.unregister();\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/hooks/api/Hook.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.hooks.api;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\n\npublic interface Hook {\n    void init(OCMMain plugin);\n\n    void deinit(OCMMain plugin);\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackCooldown.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.cryptomorin.xseries.XAttribute;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.attribute.AttributeInstance;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.player.PlayerChangedWorldEvent;\nimport org.bukkit.event.player.PlayerItemHeldEvent;\nimport org.bukkit.event.player.PlayerJoinEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent;\nimport org.bukkit.inventory.ItemStack;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n/**\n * Disables the attack cooldown.\n */\npublic class ModuleAttackCooldown extends OCMModule {\n\n    private static final double VANILLA_ATTACK_SPEED = 4.0;\n\n    private double genericAttackSpeed = 40.0;\n    private Map<Material, Double> heldItemAttackSpeeds = Collections.emptyMap();\n\n    public ModuleAttackCooldown(OCMMain plugin) {\n        super(plugin, \"disable-attack-cooldown\");\n    }\n\n    @Override\n    public void reload() {\n        genericAttackSpeed = module().getDouble(\"generic-attack-speed\", 40.0);\n        heldItemAttackSpeeds = Collections.emptyMap();\n\n        if (module().isConfigurationSection(\"held-item-attack-speeds\")) {\n            heldItemAttackSpeeds = ConfigUtils.loadMaterialDoubleMap(module().getConfigurationSection(\"held-item-attack-speeds\"));\n        }\n\n        Bukkit.getOnlinePlayers().forEach(this::adjustAttackSpeed);\n    }\n\n    @EventHandler(priority = EventPriority.HIGH)\n    public void onPlayerLogin(PlayerJoinEvent e) {\n        adjustAttackSpeed(e.getPlayer());\n    }\n\n    @EventHandler(priority = EventPriority.HIGH)\n    public void onWorldChange(PlayerChangedWorldEvent e) {\n        adjustAttackSpeed(e.getPlayer());\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onHotbarChange(PlayerItemHeldEvent e) {\n        if (e.isCancelled()) {\n            adjustAttackSpeed(e.getPlayer());\n        } else {\n            adjustAttackSpeed(e.getPlayer(), e.getPlayer().getInventory().getItem(e.getNewSlot()));\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onSwapHandItems(PlayerSwapHandItemsEvent e) {\n        if (e.isCancelled()) {\n            adjustAttackSpeed(e.getPlayer());\n        } else {\n            adjustAttackSpeed(e.getPlayer(), e.getOffHandItem());\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGH)\n    public void onPlayerQuit(PlayerQuitEvent e) {\n        setAttackSpeed(e.getPlayer(), VANILLA_ATTACK_SPEED);\n    }\n\n    /**\n     * Adjusts the attack speed to the default or configured value, depending on\n     * whether the module is enabled.\n     *\n     * @param player the player to set the attack speed for\n     */\n    private void adjustAttackSpeed(Player player) {\n        adjustAttackSpeed(player, player.getInventory().getItemInMainHand());\n    }\n\n    private void adjustAttackSpeed(Player player, ItemStack mainHand) {\n        final double attackSpeed = isEnabled(player)\n                ? getConfiguredAttackSpeed(mainHand)\n                : VANILLA_ATTACK_SPEED;\n\n        setAttackSpeed(player, attackSpeed);\n    }\n\n    @Override\n    public void onModesetChange(Player player) {\n        adjustAttackSpeed(player);\n    }\n\n    private double getConfiguredAttackSpeed(ItemStack itemStack) {\n        if (itemStack == null) {\n            return genericAttackSpeed;\n        }\n\n        return heldItemAttackSpeeds.getOrDefault(itemStack.getType(), genericAttackSpeed);\n    }\n\n    /**\n     * Sets the attack speed to the given value.\n     *\n     * @param player      the player to set it for\n     * @param attackSpeed the attack speed to set it to\n     */\n    public void setAttackSpeed(Player player, double attackSpeed) {\n        final AttributeInstance attribute = player.getAttribute(XAttribute.ATTACK_SPEED.get());\n        if (attribute == null)\n            return;\n\n        final double baseValue = attribute.getBaseValue();\n\n        if (baseValue != attackSpeed) {\n            debug(String.format(\"Setting attack speed to %.2f (was: %.2f)\", attackSpeed, baseValue), player);\n\n            attribute.setBaseValue(attackSpeed);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackFrequency.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Location;\nimport org.bukkit.World;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.entity.CreatureSpawnEvent;\nimport org.bukkit.event.entity.EntityTeleportEvent;\nimport org.bukkit.event.player.PlayerChangedWorldEvent;\nimport org.bukkit.event.player.PlayerJoinEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.event.player.PlayerRespawnEvent;\n\npublic class ModuleAttackFrequency extends OCMModule {\n\n    private static final int DEFAULT_DELAY = 20;\n    private static int playerDelay, mobDelay;\n\n    public ModuleAttackFrequency(OCMMain plugin) {\n        super(plugin, \"attack-frequency\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        playerDelay = module().getInt(\"playerDelay\");\n        mobDelay = module().getInt(\"mobDelay\");\n\n        Bukkit.getWorlds().forEach(world -> world.getLivingEntities().forEach(livingEntity -> {\n            if (livingEntity instanceof Player)\n                livingEntity.setMaximumNoDamageTicks(isEnabled((Player) livingEntity) ? playerDelay : DEFAULT_DELAY);\n            else\n                livingEntity.setMaximumNoDamageTicks(isEnabled(world) ? mobDelay : DEFAULT_DELAY);\n        }));\n    }\n\n    @EventHandler\n    public void onPlayerJoin(PlayerJoinEvent e) {\n        final Player player = e.getPlayer();\n        if (isEnabled(player)) setDelay(player, playerDelay);\n    }\n\n    @EventHandler\n    public void onPlayerLogout(PlayerQuitEvent e) {\n        setDelay(e.getPlayer(), DEFAULT_DELAY);\n    }\n\n    @EventHandler\n    public void onPlayerChangeWorld(PlayerChangedWorldEvent e) {\n        final Player player = e.getPlayer();\n        setDelay(player, isEnabled(player) ? playerDelay : DEFAULT_DELAY);\n    }\n\n    @EventHandler\n    public void onPlayerRespawn(PlayerRespawnEvent e) {\n        final Player player = e.getPlayer();\n        setDelay(player, isEnabled(player) ? playerDelay : DEFAULT_DELAY);\n    }\n\n    private void setDelay(Player player, int delay) {\n        player.setMaximumNoDamageTicks(delay);\n        debug(\"Set hit delay to \" + delay, player);\n    }\n\n    @EventHandler\n    public void onCreatureSpawn(CreatureSpawnEvent e) {\n        final LivingEntity livingEntity = e.getEntity();\n        final World world = livingEntity.getWorld();\n        if (isEnabled(world)) livingEntity.setMaximumNoDamageTicks(mobDelay);\n    }\n\n    @EventHandler\n    public void onEntityTeleportEvent(EntityTeleportEvent e) {\n        // This event is only fired for non-player entities\n        final Entity entity = e.getEntity();\n        if (!(entity instanceof LivingEntity)) return;\n        final LivingEntity livingEntity = (LivingEntity) entity;\n\n        final World fromWorld = e.getFrom().getWorld();\n        final Location toLocation = e.getTo();\n        if(toLocation == null) return;\n        final World toWorld = toLocation.getWorld();\n        if (fromWorld.getUID() != toWorld.getUID())\n            livingEntity.setMaximumNoDamageTicks(isEnabled(toWorld) ? mobDelay : DEFAULT_DELAY);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackRange.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.Listener;\nimport org.bukkit.event.entity.PlayerDeathEvent;\nimport org.bukkit.event.player.PlayerDropItemEvent;\nimport org.bukkit.event.player.PlayerItemHeldEvent;\nimport org.bukkit.event.player.PlayerJoinEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent;\nimport org.bukkit.event.player.PlayerChangedWorldEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.plugin.Plugin;\n\nimport java.util.Arrays;\nimport java.util.Locale;\nimport java.util.function.Predicate;\nimport java.lang.reflect.Method;\n\n/**\n * Applies the 1.8-style attack range (reach + hitbox margin) to melee weapons on 1.21.11+ Paper.\n * Gracefully disables itself on Spigot or older versions where the AttackRange data component is absent.\n */\npublic class ModuleAttackRange extends OCMModule implements Listener {\n\n    private static final String[] WEAPONS = {\"sword\", \"axe\", \"pickaxe\", \"spade\", \"shovel\", \"hoe\", \"trident\", \"mace\"};\n\n    private boolean supported;\n    private float minRange;\n    private float maxRange;\n    private float minCreative;\n    private float maxCreative;\n    private float hitboxMargin;\n    private float mobFactor;\n\n    private PaperAttackRangeAdapter paperAdapter;\n\n    public ModuleAttackRange(OCMMain plugin) {\n        super(plugin, \"attack-range\");\n        initialiseReflection();\n        registerCleanerListener(plugin);\n        reload();\n    }\n\n    private void initialiseReflection() {\n        if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) {\n            supported = false;\n            return;\n        }\n        try {\n            paperAdapter = new PaperAttackRangeAdapter();\n            supported = true;\n        } catch (Throwable t) {\n            supported = false;\n            Messenger.warn(\"Attack range component API not available (Paper 1.21.11+ required); module disabled. (\" + t.getClass().getSimpleName() + \": \" + t.getMessage() + \")\");\n        }\n    }\n\n    @Override\n    public void reload() {\n        if (!supported) return;\n\n        minRange = (float) module().getDouble(\"min-range\", 0.0);\n        maxRange = (float) module().getDouble(\"max-range\", 3.0);\n        minCreative = (float) module().getDouble(\"min-creative-range\", 0.0);\n        maxCreative = (float) module().getDouble(\"max-creative-range\", 4.0);\n        hitboxMargin = (float) module().getDouble(\"hitbox-margin\", 0.1);\n        mobFactor = (float) module().getDouble(\"mob-factor\", 1.0);\n\n        // Apply to currently online players so config changes take effect immediately\n        Bukkit.getOnlinePlayers().forEach(this::applyToHeld);\n    }\n\n    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n    public void onJoin(PlayerJoinEvent event) {\n        applyToHeld(event.getPlayer());\n    }\n\n    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n    public void onHotbar(PlayerItemHeldEvent event) {\n        // strip old, then apply/strip new\n        cleanHand(event.getPlayer(), event.getPreviousSlot());\n        applyToHeld(event.getPlayer());\n    }\n\n    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n    public void onSwap(PlayerSwapHandItemsEvent event) {\n        normaliseSwapEvent(event);\n        reconcileSwapInventory(event.getPlayer());\n    }\n\n    private void normaliseSwapEvent(PlayerSwapHandItemsEvent event) {\n        Player player = event.getPlayer();\n        if (!supported) return;\n\n        ItemStack postSwapMainHand = event.getOffHandItem();\n        ItemStack postSwapOffHand = event.getMainHandItem();\n        stripComponent(postSwapOffHand);\n        applyToItem(player, postSwapMainHand);\n\n        // Persist adjusted stacks into event payload for synthetic/manual swap flows.\n        event.setOffHandItem(postSwapMainHand);\n        event.setMainHandItem(postSwapOffHand);\n    }\n\n    private void reconcileSwapInventory(Player player) {\n        if (!supported) return;\n\n        Bukkit.getScheduler().runTask(plugin, () -> {\n            if (!player.isOnline()) return;\n\n            ItemStack mainHand = player.getInventory().getItemInMainHand();\n            ItemStack offHand = player.getInventory().getItemInOffHand();\n\n            stripComponent(mainHand);\n            stripComponent(offHand);\n            applyToItem(player, mainHand);\n        });\n    }\n\n    private void applyToHeld(Player player) {\n        if (!supported) return;\n        ItemStack item = player.getInventory().getItemInMainHand();\n        if (item == null || item.getType() == Material.AIR) return;\n        applyToItem(player, item);\n    }\n\n    private void applyToItem(Player player, ItemStack item) {\n        if (item == null || item.getType() == Material.AIR || !isWeapon(item.getType())) {\n            stripComponent(item);\n            return;\n        }\n        if (!isEnabled(player)) {\n            stripComponent(item);\n            return;\n        }\n        applyAttackRange(item);\n    }\n\n    private void cleanHand(Player player, int slot) {\n        ItemStack old = player.getInventory().getItem(slot);\n        stripComponent(old);\n    }\n\n    private boolean isWeapon(Material material) {\n        final String name = material.name().toLowerCase(Locale.ROOT);\n        return Arrays.stream(WEAPONS).anyMatch(name::endsWith);\n    }\n\n    private void applyAttackRange(ItemStack item) {\n        paperAdapter.apply(item, minRange, maxRange, minCreative, maxCreative, hitboxMargin, mobFactor);\n    }\n\n    private void stripComponent(ItemStack item) {\n        if (!supported || paperAdapter == null || item == null) return;\n        paperAdapter.clear(item);\n    }\n\n    private void registerCleanerListener(Plugin plugin) {\n        Bukkit.getPluginManager().registerEvents(new CleanerListener(), plugin);\n    }\n\n    /**\n     * Always-on listener that strips the component when the item leaves hand or is dropped,\n     * preventing lingering modified stacks even when the module is disabled.\n     */\n    private class CleanerListener implements Listener {\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onHeldChange(PlayerItemHeldEvent event) {\n            cleanHand(event.getPlayer(), event.getPreviousSlot());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onSwap(PlayerSwapHandItemsEvent event) {\n            // Handled by the module listener; avoid clobbering its swap normalisation.\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onDrop(PlayerDropItemEvent event) {\n            stripComponent(event.getItemDrop().getItemStack());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onDeath(PlayerDeathEvent event) {\n            event.getDrops().forEach(ModuleAttackRange.this::stripComponent);\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onQuit(PlayerQuitEvent event) {\n            stripComponent(event.getPlayer().getInventory().getItemInMainHand());\n            stripComponent(event.getPlayer().getInventory().getItemInOffHand());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onWorldChange(PlayerChangedWorldEvent event) {\n            stripComponent(event.getPlayer().getInventory().getItemInMainHand());\n            stripComponent(event.getPlayer().getInventory().getItemInOffHand());\n            applyToHeld(event.getPlayer());\n        }\n    }\n\n    /**\n     * Paper-only adapter to avoid reflection in hot paths.\n     */\n    private static class PaperAttackRangeAdapter {\n        @SuppressWarnings(\"unchecked\")\n        private static final Predicate<Object> COPY_ALL_COMPONENTS = ignored -> true;\n\n        private final Object attackRangeType;\n        private final java.lang.reflect.Method attackRangeFactory;\n        private final java.lang.reflect.Method minReachSetter;\n        private final java.lang.reflect.Method maxReachSetter;\n        private final java.lang.reflect.Method minCreativeSetter;\n        private final java.lang.reflect.Method maxCreativeSetter;\n        private final java.lang.reflect.Method hitboxSetter;\n        private final java.lang.reflect.Method mobFactorSetter;\n        private final java.lang.reflect.Method buildMethod;\n        private final java.lang.reflect.Method itemSetData;\n        private final java.lang.reflect.Method itemHasData;\n        private final java.lang.reflect.Method itemUnsetData;\n        private final java.lang.reflect.Method itemEnsureServerConversions;\n        private final java.lang.reflect.Method itemCopyDataFrom;\n        private boolean warned;\n\n        PaperAttackRangeAdapter() throws Exception {\n            Class<?> dct = Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\");\n            Class<?> ar = Class.forName(\"io.papermc.paper.datacomponent.item.AttackRange\");\n            Class<?> builder = Class.forName(\"io.papermc.paper.datacomponent.item.AttackRange$Builder\");\n            attackRangeType = dct.getField(\"ATTACK_RANGE\").get(null);\n            attackRangeFactory = ar.getMethod(\"attackRange\");\n            minReachSetter = builder.getMethod(\"minReach\", float.class);\n            maxReachSetter = builder.getMethod(\"maxReach\", float.class);\n            minCreativeSetter = builder.getMethod(\"minCreativeReach\", float.class);\n            maxCreativeSetter = builder.getMethod(\"maxCreativeReach\", float.class);\n            hitboxSetter = builder.getMethod(\"hitboxMargin\", float.class);\n            mobFactorSetter = builder.getMethod(\"mobFactor\", float.class);\n            buildMethod = builder.getMethod(\"build\");\n            Class<?> dctClass = Class.forName(\"io.papermc.paper.datacomponent.DataComponentType\");\n            itemSetData = findSetDataMethod(dctClass, ar);\n            itemHasData = ItemStack.class.getMethod(\"hasData\", dctClass);\n            itemUnsetData = ItemStack.class.getMethod(\"unsetData\", dctClass);\n\n            Method ensureMethod = null;\n            Method copyMethod = null;\n            try {\n                ensureMethod = ItemStack.class.getMethod(\"ensureServerConversions\");\n                copyMethod = ItemStack.class.getMethod(\"copyDataFrom\", ItemStack.class, Predicate.class);\n            } catch (NoSuchMethodException ignored) {\n                // Older/newer API shape; keep as best-effort no-op.\n            }\n            itemEnsureServerConversions = ensureMethod;\n            itemCopyDataFrom = copyMethod;\n        }\n\n        private Method findSetDataMethod(Class<?> dctClass, Class<?> valueClass) throws NoSuchMethodException {\n            for (Method m : ItemStack.class.getMethods()) {\n                if (!m.getName().equals(\"setData\")) continue;\n                Class<?>[] params = m.getParameterTypes();\n                if (params.length != 2) continue;\n                // accept any data component type class\n                if (!dctClass.isAssignableFrom(params[0]) && !params[0].getName().contains(\"DataComponentType\")) continue;\n                if (!params[1].isAssignableFrom(valueClass) && !valueClass.isAssignableFrom(params[1]) && !params[1].isAssignableFrom(Object.class)) continue;\n                return m;\n            }\n            throw new NoSuchMethodException(ItemStack.class.getName() + \"#setData(DataComponentType, AttackRange)\");\n        }\n\n        void apply(ItemStack stack, float min, float max, float minCreative, float maxCreative, float margin, float mobFactor) {\n            try {\n                Object builder = attackRangeFactory.invoke(null);\n                invokeSetter(minReachSetter, builder, min);\n                invokeSetter(maxReachSetter, builder, max);\n                invokeSetter(minCreativeSetter, builder, minCreative);\n                invokeSetter(maxCreativeSetter, builder, maxCreative);\n                invokeSetter(hitboxSetter, builder, margin);\n                invokeSetter(mobFactorSetter, builder, mobFactor);\n                Object arObj = buildMethod.invoke(builder);\n                itemSetData.invoke(stack, attackRangeType, arObj);\n                ensureServerConversions(stack);\n            } catch (Throwable t) {\n                if (!warned) {\n                    Messenger.warn(\"Attack range component application failed; leaving item unchanged. (\" + t.getClass().getSimpleName() + \": \" + t.getMessage() + \")\");\n                    warned = true;\n                }\n            }\n        }\n\n        private void invokeSetter(Method setter, Object builder, float value) throws Exception {\n            Object result = setter.invoke(builder, value);\n            if (result != null && !setter.getReturnType().equals(void.class) && !setter.getReturnType().equals(Void.class)) {\n                // Some Paper versions return the builder for chaining; others mutate in place.\n                // We do not need to capture the returned value because all calls target the same instance.\n            }\n        }\n\n        boolean hasComponent(ItemStack stack) {\n            try {\n                return (boolean) itemHasData.invoke(stack, attackRangeType);\n            } catch (Throwable t) {\n                return false;\n            }\n        }\n\n        void clear(ItemStack stack) {\n            try {\n                if (hasComponent(stack)) {\n                    itemUnsetData.invoke(stack, attackRangeType);\n                    ensureServerConversions(stack);\n                }\n            } catch (Throwable ignored) {\n                // ignore\n            }\n        }\n\n        private void ensureServerConversions(ItemStack stack) {\n            if (stack == null || itemEnsureServerConversions == null || itemCopyDataFrom == null) return;\n            try {\n                Object converted = itemEnsureServerConversions.invoke(stack);\n                if (!(converted instanceof ItemStack)) return;\n                if (converted == stack) return;\n                itemCopyDataFrom.invoke(stack, converted, COPY_ALL_COMPONENTS);\n            } catch (Throwable ignored) {\n                // no-op: best-effort sync only\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackSounds.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.github.retrooper.packetevents.PacketEvents;\nimport com.github.retrooper.packetevents.event.PacketListenerAbstract;\nimport com.github.retrooper.packetevents.event.PacketSendEvent;\nimport com.github.retrooper.packetevents.protocol.packettype.PacketType;\nimport com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSoundEffect;\nimport com.cryptomorin.xseries.XSound;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport org.bukkit.Sound;\nimport org.bukkit.entity.Player;\n\nimport java.util.Collection;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.lang.reflect.Method;\n\n/**\n * A module to disable the new attack sounds.\n */\npublic class ModuleAttackSounds extends OCMModule {\n\n    private final SoundListener soundListener = new SoundListener();\n    private final Set<String> blockedSounds = new HashSet<>();\n\n    public ModuleAttackSounds(OCMMain plugin) {\n        super(plugin, \"disable-attack-sounds\");\n\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        blockedSounds.clear();\n        blockedSounds.addAll(getBlockedSounds());\n\n        if (isEnabled() && !blockedSounds.isEmpty())\n            PacketEvents.getAPI().getEventManager().registerListener(soundListener);\n        else\n            PacketEvents.getAPI().getEventManager().unregisterListener(soundListener);\n    }\n\n    private Collection<String> getBlockedSounds() {\n        List<String> fromConfig = module().getStringList(\"blocked-sound-names\");\n        Set<String> processed = new HashSet<>();\n        for (String soundName : fromConfig) {\n            Optional<XSound> xSound = XSound.matchXSound(soundName);\n            if (xSound.isPresent()) {\n                Sound sound = xSound.get().parseSound();\n                if (sound != null) {\n                    // On modern versions, we can get the namespaced key directly\n                    try {\n                        Method getKeyMethod = Sound.class.getMethod(\"getKey\");\n                        Object key = getKeyMethod.invoke(sound);\n                        processed.add(key.toString());\n                        continue;\n                    } catch (Exception ignored) {\n                        // This server version doesn't have the getKey method, so we fall back to the\n                        // legacy name\n                    }\n                }\n                // Fallback for older versions or if the sound is not in the Bukkit enum\n                String processedName = soundName.toLowerCase(Locale.ROOT).replace('_', '.');\n                if (!processedName.contains(\":\")) {\n                    processedName = \"minecraft:\" + processedName;\n                }\n                processed.add(processedName);\n            } else {\n                Messenger.warn(\"Invalid sound name in config: \" + soundName);\n            }\n        }\n        return processed;\n    }\n\n    /**\n     * Disables attack sounds.\n     */\n    private class SoundListener extends PacketListenerAbstract {\n        private boolean disabledDueToError;\n\n        @Override\n        public void onPacketSend(PacketSendEvent packetEvent) {\n            if (disabledDueToError || packetEvent.isCancelled())\n                return;\n            if (blockedSounds.isEmpty())\n                return;\n\n            final Object playerObject = packetEvent.getPlayer();\n            if (!(playerObject instanceof Player))\n                return;\n\n            final Player player = (Player) playerObject;\n            if (!isEnabled(player))\n                return;\n\n            final Object packetType = packetEvent.getPacketType();\n            if (!PacketType.Play.Server.NAMED_SOUND_EFFECT.equals(packetType)\n                    && !PacketType.Play.Server.SOUND_EFFECT.equals(packetType)) {\n                return;\n            }\n\n            try {\n                WrapperPlayServerSoundEffect wrapper = new WrapperPlayServerSoundEffect(packetEvent);\n                com.github.retrooper.packetevents.protocol.sound.Sound sound = wrapper.getSound();\n                if (sound == null || sound.getSoundId() == null)\n                    return;\n\n                String soundName = sound.getSoundId().toString();\n                if (blockedSounds.contains(soundName)) {\n                    packetEvent.setCancelled(true);\n                    debug(\"Blocked sound \" + soundName, player);\n                }\n            } catch (Exception | ExceptionInInitializerError e) {\n                disabledDueToError = true;\n                Messenger.warn(\n                        e,\n                        \"Error detecting sound packets. Please report it along with the following exception \" +\n                                \"on github.\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleChorusFruit.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.MathsHelper;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Location;\nimport org.bukkit.Material;\nimport org.bukkit.World;\nimport org.bukkit.block.Block;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.player.PlayerItemConsumeEvent;\nimport org.bukkit.event.player.PlayerTeleportEvent;\nimport org.bukkit.block.BlockFace;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\n/**\n * A module to control chorus fruits.\n */\npublic class ModuleChorusFruit extends OCMModule {\n\n    public ModuleChorusFruit(OCMMain plugin) {\n        super(plugin, \"chorus-fruit\");\n    }\n\n    @EventHandler\n    public void onEat(PlayerItemConsumeEvent e) {\n        if (e.getItem().getType() != Material.CHORUS_FRUIT) return;\n        final Player player = e.getPlayer();\n\n        if (!isEnabled(player)) return;\n\n        if (module().getBoolean(\"prevent-eating\")) {\n            e.setCancelled(true);\n            return;\n        }\n\n        final int hungerValue = module().getInt(\"hunger-value\");\n        final double saturationValue = module().getDouble(\"saturation-value\");\n        final int previousFoodLevel = player.getFoodLevel();\n        final float previousSaturation = player.getSaturation();\n\n        // Run it on the next tick to reset things while not cancelling the chorus fruit eat event\n        // This ensures the teleport event is fired and counts towards statistics\n        Bukkit.getScheduler().runTaskLater(plugin, () -> {\n            final int newFoodLevel = Math.min(hungerValue + previousFoodLevel, 20);\n            final float newSaturation = Math.min((float) (saturationValue + previousSaturation), newFoodLevel);\n\n            player.setFoodLevel(newFoodLevel);\n            player.setSaturation(newSaturation);\n\n            debug(\"Food level changed from: \" + previousFoodLevel + \" to \" + player.getFoodLevel(), player);\n        }, 2L);\n    }\n\n    @EventHandler\n    public void onTeleport(PlayerTeleportEvent e) {\n        if (e.getCause() != PlayerTeleportEvent.TeleportCause.CHORUS_FRUIT) return;\n\n        final Player player = e.getPlayer();\n        if (!isEnabled(player)) return;\n\n        final double distance = getMaxTeleportationDistance();\n\n        if (distance == 8) {\n            debug(\"Using vanilla teleport implementation!\", player);\n            return;\n        }\n\n        if (distance <= 0) {\n            debug(\"Chorus teleportation is not allowed\", player);\n            e.setCancelled(true);\n            return;\n        }\n\n        // Not sure when this can occur, but it is marked as @Nullable\n        final Location toLocation = e.getTo();\n\n        if (toLocation == null) {\n            debug(\"Teleport target is null\", player);\n            return;\n        }\n\n        final int maxheight = toLocation.getWorld().getMaxHeight();\n\n        final Location origin = player.getLocation();\n        final World world = origin.getWorld();\n        final ThreadLocalRandom rng = ThreadLocalRandom.current();\n\n        Location chosen = null;\n        // Mirror vanilla chorus fruit: up to 16 attempts to find a safe spot\n        for (int i = 0; i < 16; i++) {\n            final double x = origin.getX() + (rng.nextDouble() - 0.5D) * 2 * distance;\n            final double y = MathsHelper.clamp(origin.getY() + rng.nextInt(Math.max(1, (int) Math.ceil(distance))), 0,\n                    maxheight - 1);\n            final double z = origin.getZ() + (rng.nextDouble() - 0.5D) * 2 * distance;\n            final Location candidate = new Location(world, x, y, z);\n\n            if (!world.getWorldBorder().isInside(candidate)) continue;\n            if (!isSafe(candidate)) continue;\n\n            chosen = candidate;\n            break;\n        }\n\n        if (chosen == null) {\n            debug(\"No safe chorus teleport found within distance \" + distance + \", keeping vanilla target\", player);\n            return;\n        }\n\n        e.setTo(chosen);\n        debug(\"Chorus teleport redirected to safe location \" + chosen, player);\n    }\n\n\n    private double getMaxTeleportationDistance() {\n        return module().getDouble(\"max-teleportation-distance\");\n    }\n\n    private boolean isSafe(Location location) {\n        Block feet = location.getBlock();\n        Block head = feet.getRelative(BlockFace.UP);\n        Block below = feet.getRelative(BlockFace.DOWN);\n\n        boolean modern = Reflector.versionIsNewerOrEqualTo(1, 13, 0);\n        boolean feetPassable = modern ? feet.isPassable() : !feet.getType().isSolid();\n        boolean headPassable = modern ? head.isPassable() : !head.getType().isSolid();\n\n        if (!feetPassable || !headPassable) return false;\n        if (!below.getType().isSolid()) return false;\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleDisableCrafting.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport org.bukkit.Material;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.inventory.PrepareItemCraftEvent;\nimport org.bukkit.inventory.CraftingInventory;\nimport org.bukkit.inventory.ItemStack;\n\nimport java.util.List;\n\n/**\n * Makes the specified materials uncraftable.\n */\npublic class ModuleDisableCrafting extends OCMModule {\n\n    private List<Material> denied;\n    private String message;\n\n    public ModuleDisableCrafting(OCMMain plugin) {\n        super(plugin, \"disable-crafting\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        denied = ConfigUtils.loadMaterialList(module(), \"denied\");\n        message = module().getBoolean(\"showMessage\") ? module().getString(\"message\") : null;\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPrepareItemCraft(PrepareItemCraftEvent e) {\n        final List<HumanEntity> viewers = e.getViewers();\n        if (viewers.size() == 0) return;\n\n        if (!isEnabled(viewers.get(0))) return;\n\n        final CraftingInventory inv = e.getInventory();\n        final ItemStack result = inv.getResult();\n\n        if (result != null && denied.contains(result.getType())) {\n            inv.setResult(null);\n            if (message != null) viewers.forEach(viewer -> Messenger.send(viewer, message));\n        }\n    }\n}"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleDisableEnderpearlCooldown.java",
    "content": "/*\r\n * This Source Code Form is subject to the terms of the Mozilla Public\r\n * License, v. 2.0. If a copy of the MPL was not distributed with this\r\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\r\n */\r\npackage kernitus.plugin.OldCombatMechanics.module;\r\n\r\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\r\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\r\nimport org.bukkit.GameMode;\r\nimport org.bukkit.Material;\r\nimport org.bukkit.entity.EnderPearl;\r\nimport org.bukkit.entity.Player;\r\nimport org.bukkit.entity.Projectile;\r\nimport org.bukkit.event.EventHandler;\r\nimport org.bukkit.event.EventPriority;\r\nimport org.bukkit.event.entity.ProjectileLaunchEvent;\r\nimport org.bukkit.event.player.PlayerQuitEvent;\r\nimport org.bukkit.inventory.ItemStack;\r\nimport org.bukkit.inventory.PlayerInventory;\r\nimport org.bukkit.projectiles.ProjectileSource;\r\n\r\nimport java.util.*;\r\n\r\n/**\r\n * Allows you to throw enderpearls as often as you like, not only after a cooldown.\r\n */\r\npublic class ModuleDisableEnderpearlCooldown extends OCMModule {\n\r\n    /**\r\n     * Contains players that threw an ender pearl. As the handler calls launchProjectile,\r\n     * which also calls ProjectileLaunchEvent, we need to ignore that event call.\r\n     */\r\n    private final Set<UUID> ignoredPlayers = new HashSet<>();\r\n    private Map<UUID, Long> lastLaunched;\n    private int cooldown;\n    private String message;\n    private static ModuleDisableEnderpearlCooldown INSTANCE;\n\r\n    public ModuleDisableEnderpearlCooldown(OCMMain plugin) {\r\n        super(plugin, \"disable-enderpearl-cooldown\");\r\n        INSTANCE = this;\r\n        reload();\r\n    }\r\n\r\n    public void reload() {\n        cooldown = module().getInt(\"cooldown\");\n        if (cooldown > 0) {\n            // Performance/correctness:\n            // - Use a normal HashMap; WeakHashMap<UUID, ...> can drop entries unpredictably.\n            // - Avoid a recurring cleanup task: expired entries are dropped lazily during checks.\n            if (lastLaunched == null) lastLaunched = new HashMap<>();\n        } else lastLaunched = null;\n\n        message = module().getBoolean(\"showMessage\") ? module().getString(\"message\") : null;\n    }\n\r\n    public static ModuleDisableEnderpearlCooldown getInstance() {\r\n        return INSTANCE;\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.HIGHEST)\r\n    public void onPlayerShoot(ProjectileLaunchEvent e) {\r\n        if (e.isCancelled()) return; // For compatibility with other plugins\r\n\r\n        final Projectile projectile = e.getEntity();\r\n        if (!(projectile instanceof EnderPearl)) return;\r\n        final ProjectileSource shooter = projectile.getShooter();\r\n\r\n        if (!(shooter instanceof Player)) return;\r\n        final Player player = (Player) shooter;\r\n\r\n        if (!isEnabled(player)) return;\r\n\r\n        final UUID uuid = player.getUniqueId();\r\n\r\n        if (ignoredPlayers.contains(uuid)) return;\r\n\r\n        e.setCancelled(true);\r\n\r\n        // Check if the cooldown has expired yet\n        if (lastLaunched != null) {\n            // Intentionally wall-clock driven: enderpearl cooldowns are typically expected to be real-time, even\n            // when TPS drops, unlike 1.8 natural regen which is tick based.\n            final long currentTime = System.currentTimeMillis() / 1000;\n            if (lastLaunched.containsKey(uuid)) {\n                final long elapsedSeconds = currentTime - lastLaunched.get(uuid);\n                if (elapsedSeconds < cooldown) {\n                    if (message != null) Messenger.send(player, message, cooldown - elapsedSeconds);\n                    return;\n                }\n                // Lazy expiry cleanup to keep the map bounded without a recurring task.\n                lastLaunched.remove(uuid);\n            }\n\n            lastLaunched.put(uuid, currentTime);\n        }\n\r\n        // Make sure we ignore the event triggered by launchProjectile\r\n        ignoredPlayers.add(uuid);\r\n        final EnderPearl pearl = player.launchProjectile(EnderPearl.class);\r\n        ignoredPlayers.remove(uuid);\r\n\r\n        pearl.setVelocity(player.getEyeLocation().getDirection().multiply(2));\r\n\r\n        if (player.getGameMode() == GameMode.CREATIVE) return;\r\n\r\n        final ItemStack enderpearlItemStack;\r\n        final PlayerInventory playerInventory = player.getInventory();\r\n        final ItemStack mainHand = playerInventory.getItemInMainHand();\r\n        final ItemStack offHand = playerInventory.getItemInOffHand();\r\n\r\n        if (isEnderPearl(mainHand)) enderpearlItemStack = mainHand;\r\n        else if (isEnderPearl(offHand)) enderpearlItemStack = offHand;\r\n        else return;\r\n\r\n        enderpearlItemStack.setAmount(enderpearlItemStack.getAmount() - 1);\r\n    }\r\n\r\n    private boolean isEnderPearl(ItemStack itemStack) {\r\n        return itemStack != null && itemStack.getType() == Material.ENDER_PEARL;\r\n    }\r\n\r\n    @EventHandler\r\n    public void onPlayerQuit(PlayerQuitEvent e) {\r\n        if (lastLaunched != null) lastLaunched.remove(e.getPlayer().getUniqueId());\r\n    }\r\n\r\n    /**\r\n     * Get the remaining cooldown time for ender pearls for a given player.\r\n     * @param playerUUID The UUID of the player to check the cooldown for.\r\n     * @return The remaining cooldown time in seconds, or 0 if there is no cooldown or it has expired.\r\n     */\r\n    public long getEnderpearlCooldown(UUID playerUUID) {\n        if (lastLaunched != null && lastLaunched.containsKey(playerUUID)) {\n            final long currentTime = System.currentTimeMillis() / 1000; // Current time in seconds\n            final long lastLaunchTime = lastLaunched.get(playerUUID); // Last launch time in seconds\n            final long elapsedSeconds = currentTime - lastLaunchTime;\n            final long cooldownRemaining = cooldown - elapsedSeconds;\n            if (cooldownRemaining <= 0) {\n                // Lazy expiry cleanup: if the cooldown has elapsed, drop the entry.\n                lastLaunched.remove(playerUUID);\n                return 0;\n            }\n            return Math.max(cooldownRemaining, 0); // Return the remaining cooldown or 0 if it has expired\n        }\n        return 0;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleDisableOffHand.java",
    "content": "/*\r\n * This Source Code Form is subject to the terms of the Mozilla Public\r\n * License, v. 2.0. If a copy of the MPL was not distributed with this\r\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\r\n */\r\npackage kernitus.plugin.OldCombatMechanics.module;\r\n\r\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\r\nimport kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;\r\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\r\nimport org.bukkit.Material;\r\nimport org.bukkit.command.CommandSender;\r\nimport org.bukkit.entity.HumanEntity;\r\nimport org.bukkit.entity.Player;\r\nimport org.bukkit.event.Event;\r\nimport org.bukkit.event.EventHandler;\r\nimport org.bukkit.event.EventPriority;\r\nimport org.bukkit.event.inventory.ClickType;\r\nimport org.bukkit.event.inventory.InventoryClickEvent;\r\nimport org.bukkit.event.inventory.InventoryDragEvent;\r\nimport org.bukkit.event.inventory.InventoryType;\r\nimport org.bukkit.event.player.PlayerChangedWorldEvent;\r\nimport org.bukkit.event.player.PlayerSwapHandItemsEvent;\r\nimport org.bukkit.inventory.Inventory;\r\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\r\nimport org.bukkit.inventory.ItemStack;\r\nimport org.bukkit.inventory.PlayerInventory;\r\n\r\nimport java.lang.reflect.Method;\r\nimport java.util.Collection;\r\nimport java.util.List;\r\nimport java.util.function.BiPredicate;\r\n\r\n/**\r\n * Disables usage of the off-hand.\r\n */\r\npublic class ModuleDisableOffHand extends OCMModule {\r\n\r\n    private static final int OFFHAND_SLOT = 40;\r\n    private List<Material> materials;\r\n    private String deniedMessage;\r\n    private BlockType blockType;\r\n\r\n    // Cache reflective methods used on older versions\r\n    private static volatile boolean useReflectionViewPath = false;\r\n    private static Method getViewMethod;\r\n    private static Method getBottomInventoryMethod;\r\n    private static Method getTopInventoryMethod;\r\n\r\n    public ModuleDisableOffHand(OCMMain plugin) {\r\n        super(plugin, \"disable-offhand\");\r\n        reload();\r\n    }\r\n\r\n    @Override\r\n    public void reload() {\r\n        blockType = module().getBoolean(\"whitelist\") ? BlockType.WHITELIST : BlockType.BLACKLIST;\r\n        materials = ConfigUtils.loadMaterialList(module(), \"items\");\r\n        deniedMessage = module().getString(\"denied-message\");\r\n    }\r\n\r\n    private void sendDeniedMessage(CommandSender sender) {\r\n        if (!deniedMessage.trim().isEmpty())\r\n            Messenger.send(sender, deniedMessage);\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\r\n    public void onSwapHandItems(PlayerSwapHandItemsEvent e) {\r\n        final Player player = e.getPlayer();\r\n        if (isEnabled(player) && isItemBlocked(e.getOffHandItem())) {\r\n            e.setCancelled(true);\r\n            sendDeniedMessage(player);\r\n        }\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\r\n    public void onInventoryClick(InventoryClickEvent e) {\r\n        final HumanEntity player = e.getWhoClicked();\r\n        if (!isEnabled(player))\r\n            return;\r\n        final ClickType clickType = e.getClick();\r\n\r\n        try {\r\n            if (clickType == ClickType.SWAP_OFFHAND) {\r\n                e.setResult(Event.Result.DENY);\r\n                sendDeniedMessage(player);\r\n                return;\r\n            }\r\n        } catch (NoSuchFieldError ignored) {\r\n        } // For versions below 1.16\r\n\r\n        final Inventory clickedInventory = e.getClickedInventory();\r\n        if (clickedInventory == null)\r\n            return;\r\n        final InventoryType inventoryType = clickedInventory.getType();\r\n        // Source inventory must be PLAYER\r\n        if (inventoryType != InventoryType.PLAYER)\r\n            return;\r\n\r\n        // First try the modern Bukkit API path. If that fails once (older versions),\r\n        // fall back to a cached reflection path next time onwards.\r\n        if (!useReflectionViewPath) {\r\n            try {\r\n                final Inventory bottom = e.getView().getBottomInventory();\r\n                final Inventory top = e.getView().getTopInventory();\r\n                if (bottom.getType() != InventoryType.CRAFTING && top.getType() != InventoryType.CRAFTING)\r\n                    return;\r\n            } catch (Throwable ignored) {\r\n                useReflectionViewPath = true;\r\n            }\r\n        }\r\n\r\n        if (useReflectionViewPath) {\r\n            try {\r\n                if (getViewMethod == null) {\r\n                    getViewMethod = Reflector.getMethod(e.getClass(), \"getView\");\r\n                }\r\n                final Object view = Reflector.invokeMethod(getViewMethod, e);\r\n\r\n                final Class<?> viewClass = view.getClass();\r\n                if (getBottomInventoryMethod == null) {\r\n                    getBottomInventoryMethod = Reflector.getMethod(viewClass, \"getBottomInventory\");\r\n                }\r\n                if (getTopInventoryMethod == null) {\r\n                    getTopInventoryMethod = Reflector.getMethod(viewClass, \"getTopInventory\");\r\n                }\r\n\r\n                final Inventory bottom = Reflector.invokeMethod(getBottomInventoryMethod, view);\r\n                final Inventory top = Reflector.invokeMethod(getTopInventoryMethod, view);\r\n                if (bottom.getType() != InventoryType.CRAFTING && top.getType() != InventoryType.CRAFTING)\r\n                    return;\r\n            } catch (RuntimeException exception) {\r\n                exception.printStackTrace();\r\n            }\r\n        }\r\n\r\n        // Prevent shift-clicking a shield into the offhand item slot\r\n        final ItemStack currentItem = e.getCurrentItem();\r\n        if (currentItem != null\r\n                && currentItem.getType() == Material.SHIELD\r\n                && isItemBlocked(currentItem)\r\n                && e.getSlot() != OFFHAND_SLOT\r\n                && e.isShiftClick()) {\r\n            e.setResult(Event.Result.DENY);\r\n            sendDeniedMessage(player);\r\n        }\r\n\r\n        if (e.getSlot() == OFFHAND_SLOT &&\r\n                ((clickType == ClickType.NUMBER_KEY && isItemBlocked(clickedInventory.getItem(e.getHotbarButton())))\r\n                        || isItemBlocked(e.getCursor()))) {\r\n            e.setResult(Event.Result.DENY);\r\n            sendDeniedMessage(player);\r\n        }\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\r\n    public void onInventoryDrag(InventoryDragEvent e) {\r\n        final HumanEntity player = e.getWhoClicked();\r\n        if (!isEnabled(player)\r\n                || e.getInventory().getType() != InventoryType.CRAFTING\r\n                || !e.getInventorySlots().contains(OFFHAND_SLOT))\r\n            return;\r\n\r\n        if (isItemBlocked(e.getOldCursor())) {\r\n            e.setResult(Event.Result.DENY);\r\n            sendDeniedMessage(player);\r\n        }\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\r\n    public void onWorldChange(PlayerChangedWorldEvent e) {\r\n        onModesetChange(e.getPlayer());\r\n    }\r\n\r\n    @Override\r\n    public void onModesetChange(Player player) {\r\n        if (!isEnabled(player))\r\n            return;\r\n\r\n        final PlayerInventory inventory = player.getInventory();\r\n        final ItemStack offHandItem = inventory.getItemInOffHand();\r\n\r\n        if (isItemBlocked(offHandItem)) {\r\n            sendDeniedMessage(player);\r\n            inventory.setItemInOffHand(new ItemStack(Material.AIR));\r\n            if (!inventory.addItem(offHandItem).isEmpty())\r\n                player.getWorld().dropItemNaturally(player.getLocation(), offHandItem);\r\n        }\r\n    }\r\n\r\n    private boolean isItemBlocked(ItemStack item) {\r\n        if (item == null || item.getType() == Material.AIR) {\r\n            return false;\r\n        }\r\n\r\n        return !blockType.isAllowed(materials, item.getType());\r\n    }\r\n\r\n    private enum BlockType {\r\n        WHITELIST(Collection::contains),\r\n        BLACKLIST(not(Collection::contains));\r\n\r\n        private final BiPredicate<Collection<Material>, Material> filter;\r\n\r\n        BlockType(BiPredicate<Collection<Material>, Material> filter) {\r\n            this.filter = filter;\r\n        }\r\n\r\n        /**\r\n         * Checks whether the given material is allowed.\r\n         *\r\n         * @param list    the list to use for checking\r\n         * @param toCheck the material to check\r\n         * @return true if the item is allowed, based on the list and the current mode\r\n         */\r\n        boolean isAllowed(Collection<Material> list, Material toCheck) {\r\n            return filter.test(list, toCheck);\r\n        }\r\n    }\r\n\r\n    private static <T, U> BiPredicate<T, U> not(BiPredicate<T, U> predicate) {\r\n        return predicate.negate();\r\n    }\r\n}\r\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleFishingKnockback.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport com.cryptomorin.xseries.XEntityType;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser;\nimport org.bukkit.GameMode;\nimport org.bukkit.Location;\nimport org.bukkit.entity.*;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.ProjectileHitEvent;\nimport org.bukkit.event.player.PlayerFishEvent;\nimport org.bukkit.util.Vector;\n\n/**\n * Brings back the old fishing-rod knockback.\n */\npublic class ModuleFishingKnockback extends OCMModule {\n\n    private final SpigotFunctionChooser<PlayerFishEvent, Object, Entity> getHookFunction;\n    private final SpigotFunctionChooser<ProjectileHitEvent, Object, Entity> getHitEntityFunction;\n    private boolean knockbackNonPlayerEntities;\n\n    public ModuleFishingKnockback(OCMMain plugin) {\n        super(plugin, \"old-fishing-knockback\");\n\n        reload();\n\n        getHookFunction = SpigotFunctionChooser.apiCompatReflectionCall((e, params) -> e.getHook(),\n                PlayerFishEvent.class, \"getHook\");\n        getHitEntityFunction = SpigotFunctionChooser.apiCompatCall((e, params) -> e.getHitEntity(), (e, params) -> {\n            final Entity hookEntity = e.getEntity();\n            return hookEntity.getWorld().getNearbyEntities(hookEntity.getLocation(), 0.25, 0.25, 0.25).stream()\n                    .filter(entity -> !knockbackNonPlayerEntities && entity instanceof Player)\n                    .findFirst()\n                    .orElse(null);\n        });\n    }\n\n    @Override\n    public void reload() {\n        knockbackNonPlayerEntities = isSettingEnabled(\"knockbackNonPlayerEntities\");\n    }\n\n    @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST)\n    public void onRodLand(ProjectileHitEvent event) {\n        final Entity hookEntity = event.getEntity();\n\n        final EntityType fishingBobberType = XEntityType.FISHING_BOBBER.get();\n        if (fishingBobberType == null || event.getEntityType() != fishingBobberType)\n            return;\n\n        final FishHook hook = (FishHook) hookEntity;\n\n        if (!(hook.getShooter() instanceof Player))\n            return;\n        final Player rodder = (Player) hook.getShooter();\n        if (!isEnabled(rodder))\n            return;\n\n        final Entity hitEntity = getHitEntityFunction.apply(event);\n\n        if (hitEntity == null)\n            return; // If no entity was hit\n        if (!(hitEntity instanceof LivingEntity))\n            return;\n        final LivingEntity livingEntity = (LivingEntity) hitEntity;\n        if (!knockbackNonPlayerEntities && !(hitEntity instanceof Player))\n            return;\n\n        // Do not move Citizens NPCs\n        // See https://wiki.citizensnpcs.co/API#Checking_if_an_entity_is_a_Citizens_NPC\n        if (hitEntity.hasMetadata(\"NPC\"))\n            return;\n\n        if (!knockbackNonPlayerEntities) {\n            final Player player = (Player) hitEntity;\n\n            debug(\"You were hit by a fishing rod!\", player);\n\n            if (player.equals(rodder))\n                return;\n\n            if (player.getGameMode() == GameMode.CREATIVE)\n                return;\n        }\n\n        // Check if cooldown time has elapsed\n        if (livingEntity.getNoDamageTicks() > livingEntity.getMaximumNoDamageTicks() / 2f)\n            return;\n\n        double damage = module().getDouble(\"damage\");\n        if (damage < 0)\n            damage = 0.0001;\n\n        livingEntity.damage(damage, rodder);\n        livingEntity.setVelocity(\n                calculateKnockbackVelocity(livingEntity.getVelocity(), livingEntity.getLocation(), hook.getLocation()));\n    }\n\n    private Vector calculateKnockbackVelocity(Vector currentVelocity, Location player, Location hook) {\n        double xDistance = hook.getX() - player.getX();\n        double zDistance = hook.getZ() - player.getZ();\n\n        // ensure distance is not zero and randomise in that case (I guess?)\n        while (xDistance * xDistance + zDistance * zDistance < 0.0001) {\n            xDistance = (Math.random() - Math.random()) * 0.01D;\n            zDistance = (Math.random() - Math.random()) * 0.01D;\n        }\n\n        final double distance = Math.sqrt(xDistance * xDistance + zDistance * zDistance);\n\n        double y = currentVelocity.getY() / 2;\n        double x = currentVelocity.getX() / 2;\n        double z = currentVelocity.getZ() / 2;\n\n        // Normalise distance to have similar knockback, no matter the distance\n        x -= xDistance / distance * 0.4;\n\n        // slow the fall or throw upwards\n        y += 0.4;\n\n        // Normalise distance to have similar knockback, no matter the distance\n        z -= zDistance / distance * 0.4;\n\n        // do not shoot too high up\n        if (y >= 0.4)\n            y = 0.4;\n\n        return new Vector(x, y, z);\n    }\n\n    /**\n     * This is to cancel dragging the entity closer when you reel in\n     */\n    @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST)\n    private void onReelIn(PlayerFishEvent e) {\n        if (e.getState() != PlayerFishEvent.State.CAUGHT_ENTITY)\n            return;\n        if (!isEnabled(e.getPlayer()))\n            return;\n\n        final String cancelDraggingIn = module().getString(\"cancelDraggingIn\", \"players\");\n        final boolean isPlayer = e.getCaught() instanceof HumanEntity;\n        if ((cancelDraggingIn.equals(\"players\") && isPlayer) ||\n                cancelDraggingIn.equals(\"mobs\") && !isPlayer ||\n                cancelDraggingIn.equals(\"all\")) {\n            getHookFunction.apply(e).remove(); // Remove the bobber and don't do anything else\n            e.setCancelled(true);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleFishingRodVelocity.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser;\nimport org.bukkit.Location;\nimport org.bukkit.Material;\nimport org.bukkit.entity.FishHook;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.player.PlayerFishEvent;\nimport org.bukkit.scheduler.BukkitRunnable;\nimport org.bukkit.util.Vector;\n\nimport java.util.HashSet;\nimport java.util.Iterator;\nimport java.util.Random;\nimport java.util.Set;\n\n/**\n * This module reverts fishing rod gravity and velocity back to 1.8 behaviour\n * <p>\n * Fishing rod gravity in 1.14+ is 0.03 while in 1.8 it is 0.04\n * Launch velocity in 1.9+ is also different from the 1.8 formula\n */\npublic class ModuleFishingRodVelocity extends OCMModule {\n\n    private Random random;\n    private boolean hasDifferentGravity;\n    private final Set<FishHook> activeHooks = new HashSet<>();\n    private BukkitRunnable gravityTask;\n    // In 1.12- getHook() returns a Fish which extends FishHook\n    private final SpigotFunctionChooser<PlayerFishEvent, Object, FishHook> getHook = SpigotFunctionChooser.apiCompatReflectionCall(\n            (e, params) -> e.getHook(),\n            PlayerFishEvent.class, \"getHook\"\n    );\n\n    public ModuleFishingRodVelocity(OCMMain plugin) {\n        super(plugin, \"fishing-rod-velocity\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        random = new Random();\n\n        // Versions 1.14+ have different gravity than previous versions\n        hasDifferentGravity = Reflector.versionIsNewerOrEqualTo(1, 14, 0);\n\n        if (gravityTask != null) {\n            gravityTask.cancel();\n            gravityTask = null;\n        }\n        activeHooks.clear();\n    }\n\n    @EventHandler (ignoreCancelled = true)\n    public void onFishEvent(PlayerFishEvent event) {\n        final FishHook fishHook = getHook.apply(event);\n        final Player player = event.getPlayer();\n\n        if (!isEnabled(player) || event.getState() != PlayerFishEvent.State.FISHING) return;\n\n        final Location location = event.getPlayer().getLocation();\n        final double playerYaw = location.getYaw();\n        final double playerPitch = location.getPitch();\n\n        final float oldMaxVelocity = 0.4F;\n        double velocityX = -Math.sin(playerYaw / 180.0F * (float) Math.PI) * Math.cos(playerPitch / 180.0F * (float) Math.PI) * oldMaxVelocity;\n        double velocityZ = Math.cos(playerYaw / 180.0F * (float) Math.PI) * Math.cos(playerPitch / 180.0F * (float) Math.PI) * oldMaxVelocity;\n        double velocityY = -Math.sin(playerPitch / 180.0F * (float) Math.PI) * oldMaxVelocity;\n\n        final double oldVelocityMultiplier = 1.5;\n\n        final double vectorLength = (float) Math.sqrt(velocityX * velocityX + velocityY * velocityY + velocityZ * velocityZ);\n        velocityX /= vectorLength;\n        velocityY /= vectorLength;\n        velocityZ /= vectorLength;\n\n        velocityX += random.nextGaussian() * 0.007499999832361937D;\n        velocityY += random.nextGaussian() * 0.007499999832361937D;\n        velocityZ += random.nextGaussian() * 0.007499999832361937D;\n\n        velocityX *= oldVelocityMultiplier;\n        velocityY *= oldVelocityMultiplier;\n        velocityZ *= oldVelocityMultiplier;\n\n        fishHook.setVelocity(new Vector(velocityX, velocityY, velocityZ));\n\n        if (!hasDifferentGravity) return;\n\n        // Adjust gravity on every tick unless it's in water.\n        // Performance: on 1.14+ this used to schedule one repeating task per cast/hook. When players spam rods,\n        // that can create lots of concurrent scheduled tasks. Instead, keep a set of active hooks and run one\n        // shared per-tick task only while the set is non-empty. The work is still O(active hooks), but we avoid\n        // scheduler overhead and per-hook task allocations.\n        activeHooks.add(fishHook);\n        ensureGravityTask();\n    }\n\n    private void ensureGravityTask() {\n        if (gravityTask != null) return;\n\n        gravityTask = new BukkitRunnable() {\n            @Override\n            public void run() {\n                // Stop the task as soon as it is not needed (no active hooks) to avoid a permanent every-tick cost.\n                if (activeHooks.isEmpty()) {\n                    cancel();\n                    gravityTask = null;\n                    return;\n                }\n\n                final Iterator<FishHook> it = activeHooks.iterator();\n                while (it.hasNext()) {\n                    final FishHook hook = it.next();\n                    if (hook == null || !hook.isValid() || hook.isOnGround()) {\n                        it.remove();\n                        continue;\n                    }\n\n                    // We check both conditions as sometimes it's underwater but in seagrass, or when bobbing not underwater but the material is water\n                    if (!hook.isInWater() && hook.getWorld().getBlockAt(hook.getLocation()).getType() != Material.WATER) {\n                        final Vector fVelocity = hook.getVelocity();\n                        fVelocity.setY(fVelocity.getY() - 0.01);\n                        hook.setVelocity(fVelocity);\n                    }\n                }\n\n                if (activeHooks.isEmpty()) {\n                    cancel();\n                    gravityTask = null;\n                }\n            }\n        };\n        gravityTask.runTaskTimer(plugin, 1, 1);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleGoldenApple.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.google.common.collect.ImmutableSet;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport com.cryptomorin.xseries.XMaterial;\nimport com.cryptomorin.xseries.XPotion;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.NamespacedKey;\nimport org.bukkit.configuration.ConfigurationSection;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.inventory.PrepareItemCraftEvent;\nimport org.bukkit.event.player.PlayerItemConsumeEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.inventory.ShapedRecipe;\nimport org.bukkit.potion.PotionEffect;\nimport org.bukkit.potion.PotionEffectType;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\n\n\n/**\n * Customise the golden apple effects.\n */\npublic class ModuleGoldenApple extends OCMModule {\n\n    // Default apple effects\n    // Gapple: absorption I, regen II\n    private static final Set<PotionEffectType> gappleEffects = ImmutableSet.of(PotionEffectType.ABSORPTION,\n            PotionEffectType.REGENERATION);\n    // Napple: absorption IV, regen II, fire resistance I, resistance I\n    private static final Set<PotionEffectType> nappleEffects = ImmutableSet.of(PotionEffectType.ABSORPTION,\n            PotionEffectType.REGENERATION, PotionEffectType.FIRE_RESISTANCE, XPotion.RESISTANCE.get());\n    private static final XMaterial ENCHANTED_GOLDEN_APPLE = XMaterial.ENCHANTED_GOLDEN_APPLE;\n    private List<PotionEffect> enchantedGoldenAppleEffects, goldenAppleEffects;\n    private ShapedRecipe enchantedAppleRecipe;\n    private final Set<String> warnedUnknownEffectTypes = new HashSet<>();\n\n    private Map<UUID, LastEaten> lastEaten;\n    private Cooldown cooldown;\n\n    private String normalCooldownMessage, enchantedCooldownMessage;\n    private static ModuleGoldenApple INSTANCE;\n\n    public ModuleGoldenApple(OCMMain plugin) {\n        super(plugin, \"old-golden-apples\");\n        INSTANCE = this;\n    }\n\n    @SuppressWarnings(\"deprecated\")\n    @Override\n    public void reload() {\n        normalCooldownMessage = module().getString(\"cooldown.message-normal\");\n        enchantedCooldownMessage = module().getString(\"cooldown.message-enchanted\");\n\n        cooldown = new Cooldown(\n                module().getLong(\"cooldown.normal\"),\n                module().getLong(\"cooldown.enchanted\"),\n                module().getBoolean(\"cooldown.is-shared\"));\n        lastEaten = new WeakHashMap<>();\n\n        enchantedGoldenAppleEffects = getPotionEffects(\"enchanted-golden-apple-effects\");\n        goldenAppleEffects = getPotionEffects(\"golden-apple-effects\");\n\n        try {\n            enchantedAppleRecipe = new ShapedRecipe(\n                    new NamespacedKey(plugin, \"MINECRAFT\"),\n                    createEnchantedGoldenApple());\n        } catch (NoClassDefFoundError e) {\n            enchantedAppleRecipe = new ShapedRecipe(createEnchantedGoldenApple());\n        }\n        enchantedAppleRecipe\n                .shape(\"ggg\", \"gag\", \"ggg\")\n                .setIngredient('g', Material.GOLD_BLOCK)\n                .setIngredient('a', Material.APPLE);\n\n        registerCrafting();\n    }\n\n    public static ModuleGoldenApple getInstance() {\n        return ModuleGoldenApple.INSTANCE;\n    }\n\n    private void registerCrafting() {\n        if (isEnabled() && module().getBoolean(\"enchanted-golden-apple-crafting\")) {\n            if (!Bukkit.getRecipesFor(createEnchantedGoldenApple()).isEmpty())\n                return;\n            Bukkit.addRecipe(enchantedAppleRecipe);\n            debug(\"Added napple recipe\");\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGH)\n    public void onPrepareItemCraft(PrepareItemCraftEvent e) {\n        final ItemStack item = e.getInventory().getResult();\n        if (item == null)\n            return; // This should never ever ever ever run. If it does then you probably screwed\n                    // something up.\n\n        if (isEnchantedGoldenApple(item)) {\n            final List<HumanEntity> viewers = e.getInventory().getViewers();\n            if (viewers.isEmpty()) return;\n            final HumanEntity player = viewers.get(0);\n\n            if (isSettingEnabled(\"no-conflict-mode\"))\n                return;\n\n            if (!isEnabled(player) || !isSettingEnabled(\"enchanted-golden-apple-crafting\"))\n                e.getInventory().setResult(null);\n        }\n    }\n\n    // Intentionally avoid InventoryView#getPlayer() to prevent class/interface mismatch on 1.12.\n\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\n    public void onItemConsume(PlayerItemConsumeEvent e) {\n        final Player player = e.getPlayer();\n\n        if (!isEnabled(player))\n            return;\n\n        final ItemStack originalItem = e.getItem();\n        final Material consumedMaterial = originalItem.getType();\n\n        if (consumedMaterial != Material.GOLDEN_APPLE &&\n                !isEnchantedGoldenApple(originalItem))\n            return;\n\n        final UUID uuid = player.getUniqueId();\n\n        // Check if the cooldown has expired yet\n        lastEaten.putIfAbsent(uuid, new LastEaten());\n\n        // If on cooldown send appropriate cooldown message\n        if (cooldown.isOnCooldown(originalItem, lastEaten.get(uuid))) {\n            final LastEaten le = lastEaten.get(uuid);\n\n            final long baseCooldown;\n            Instant current;\n            final String message;\n\n            if (consumedMaterial == Material.GOLDEN_APPLE) {\n                baseCooldown = cooldown.normal;\n                current = le.lastNormalEaten;\n                message = normalCooldownMessage;\n            } else {\n                baseCooldown = cooldown.enchanted;\n                current = le.lastEnchantedEaten;\n                message = enchantedCooldownMessage;\n            }\n\n            final Optional<Instant> newestEatTime = le.getNewestEatTime();\n            if (cooldown.sharedCooldown && newestEatTime.isPresent())\n                current = newestEatTime.get();\n\n            final long seconds = baseCooldown - (Instant.now().getEpochSecond() - current.getEpochSecond());\n\n            if (message != null && !message.isEmpty())\n                Messenger.send(player, message.replaceAll(\"%seconds%\", String.valueOf(seconds)));\n\n            e.setCancelled(true);\n            return;\n        }\n\n        lastEaten.get(uuid).setForItem(originalItem);\n\n        if (!isSettingEnabled(\"old-potion-effects\"))\n            return;\n\n        // Save player's current potion effects\n        final Collection<PotionEffect> previousPotionEffects = player.getActivePotionEffects();\n\n        final List<PotionEffect> newEffects = isEnchantedGoldenApple(originalItem) ? enchantedGoldenAppleEffects\n                : goldenAppleEffects;\n        final Set<PotionEffectType> defaultEffects = isEnchantedGoldenApple(originalItem) ? nappleEffects\n                : gappleEffects;\n\n        Bukkit.getScheduler().runTaskLater(plugin, () -> {\n            // Remove all potion effects the apple added\n            player.getActivePotionEffects().stream()\n                    .map(PotionEffect::getType)\n                    .filter(defaultEffects::contains)\n                    .forEach(player::removePotionEffect);\n            // Add previous potion effects from before eating the apple\n            player.addPotionEffects(previousPotionEffects);\n            // Add new custom effects from eating the apple\n            applyEffects(player, newEffects);\n        }, 1L);\n    }\n\n    private void applyEffects(LivingEntity target, List<PotionEffect> newEffects) {\n        final boolean forceOverride = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector\n                .versionIsNewerOrEqualTo(1, 13, 0);\n        for (PotionEffect newEffect : newEffects) {\n            // Find the existing effect of the same type with the highest amplifier\n            final PotionEffect highestExistingEffect = target.getActivePotionEffects().stream()\n                    .filter(e -> e.getType() == newEffect.getType())\n                    .max(Comparator.comparingInt(PotionEffect::getAmplifier))\n                    .orElse(null);\n\n            if (highestExistingEffect != null) {\n                // If the new effect has a higher amplifier, apply it\n                if (newEffect.getAmplifier() > highestExistingEffect.getAmplifier()) {\n                    if (forceOverride) {\n                        target.addPotionEffect(newEffect, true);\n                    } else {\n                        target.addPotionEffect(newEffect);\n                    }\n                }\n                // If the amplifiers are the same and the new effect has a longer duration,\n                // refresh the duration\n                else if (newEffect.getAmplifier() == highestExistingEffect.getAmplifier() &&\n                        newEffect.getDuration() > highestExistingEffect.getDuration()) {\n                    if (forceOverride) {\n                        target.addPotionEffect(newEffect, true);\n                    } else {\n                        target.addPotionEffect(newEffect);\n                    }\n                }\n                // If the new effect has a lower amplifier or shorter/equal duration, do nothing\n            } else {\n                // If there is no existing effect of the same type, apply the new effect\n                target.addPotionEffect(newEffect);\n            }\n        }\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private ItemStack createEnchantedGoldenApple() {\n        final ItemStack parsed = ENCHANTED_GOLDEN_APPLE.parseItem();\n        if (parsed != null) {\n            return parsed;\n        }\n        final ItemStack legacy = new ItemStack(Material.GOLDEN_APPLE, 1);\n        legacy.setDurability((short) 1);\n        return legacy;\n    }\n\n    private boolean isEnchantedGoldenApple(ItemStack item) {\n        return item != null && ENCHANTED_GOLDEN_APPLE.isSimilar(item);\n    }\n\n    private List<PotionEffect> getPotionEffects(String path) {\n        final List<PotionEffect> appleEffects = new ArrayList<>();\n\n        final ConfigurationSection sect = module().getConfigurationSection(path);\n        for (String key : sect.getKeys(false)) {\n            final int duration = sect.getInt(key + \".duration\") * 20; // Convert seconds to ticks\n            final int amplifier = sect.getInt(key + \".amplifier\");\n\n            final PotionEffectType type = XPotion.matchXPotion(key)\n                    .map(XPotion::get)\n                    .orElse(null);\n            if (type == null) {\n                if (warnedUnknownEffectTypes.add(key)) {\n                    Messenger.warn(\"[%s] Unknown potion effect '%s' in %s; skipping\", getModuleName(), key, path);\n                }\n                continue;\n            }\n\n            final PotionEffect fx = new PotionEffect(type, duration, amplifier);\n            appleEffects.add(fx);\n        }\n        return appleEffects;\n    }\n\n    @EventHandler\n    public void onPlayerQuit(PlayerQuitEvent e) {\n        final UUID uuid = e.getPlayer().getUniqueId();\n        if (lastEaten != null)\n            lastEaten.remove(uuid);\n    }\n\n    /**\n     * Get player's current golden apple cooldown\n     *\n     * @param playerUUID The UUID of the player to check the cooldown for.\n     * @return The remaining cooldown time in seconds, or 0 if there is no cooldown,\n     *         or it has expired.\n     */\n    public long getGappleCooldown(UUID playerUUID) {\n        final LastEaten lastEatenInfo = lastEaten.get(playerUUID);\n        if (lastEatenInfo != null && lastEatenInfo.lastNormalEaten != null) {\n            long timeElapsedSinceEaten = Duration.between(lastEatenInfo.lastNormalEaten, Instant.now()).getSeconds();\n            long cooldownRemaining = cooldown.normal - timeElapsedSinceEaten;\n            return Math.max(cooldownRemaining, 0); // Return 0 if the cooldown has expired\n        }\n        return 0;\n    }\n\n    /**\n     * Get player's current enchanted golden apple cooldown\n     *\n     * @param playerUUID The UUID of the player to check the cooldown for.\n     * @return The remaining cooldown time in seconds, or 0 if there is no cooldown,\n     *         or it has expired.\n     */\n    public long getNappleCooldown(UUID playerUUID) {\n        final LastEaten lastEatenInfo = lastEaten.get(playerUUID);\n        if (lastEatenInfo != null && lastEatenInfo.lastEnchantedEaten != null) {\n            long timeElapsedSinceEaten = Duration.between(lastEatenInfo.lastEnchantedEaten, Instant.now()).getSeconds();\n            long cooldownRemaining = cooldown.enchanted - timeElapsedSinceEaten;\n            return Math.max(cooldownRemaining, 0); // Return 0 if the cooldown has expired\n        }\n        return 0;\n    }\n\n    private static class LastEaten {\n        private Instant lastNormalEaten;\n        private Instant lastEnchantedEaten;\n\n        private Optional<Instant> getForItem(ItemStack item) {\n            return ENCHANTED_GOLDEN_APPLE.isSimilar(item)\n                    ? Optional.ofNullable(lastEnchantedEaten)\n                    : Optional.ofNullable(lastNormalEaten);\n        }\n\n        private Optional<Instant> getNewestEatTime() {\n            if (lastEnchantedEaten == null) {\n                return Optional.ofNullable(lastNormalEaten);\n            }\n            if (lastNormalEaten == null) {\n                return Optional.of(lastEnchantedEaten);\n            }\n            return Optional.of(\n                    lastNormalEaten.compareTo(lastEnchantedEaten) < 0 ? lastEnchantedEaten : lastNormalEaten);\n        }\n\n        private void setForItem(ItemStack item) {\n            if (ENCHANTED_GOLDEN_APPLE.isSimilar(item)) {\n                lastEnchantedEaten = Instant.now();\n            } else {\n                lastNormalEaten = Instant.now();\n            }\n        }\n    }\n\n    private static class Cooldown {\n        private final long normal;\n        private final long enchanted;\n        private final boolean sharedCooldown;\n\n        private Cooldown(long normal, long enchanted, boolean sharedCooldown) {\n            this.normal = normal;\n            this.enchanted = enchanted;\n            this.sharedCooldown = sharedCooldown;\n        }\n\n        private long getCooldownForItem(ItemStack item) {\n            return ENCHANTED_GOLDEN_APPLE.isSimilar(item) ? enchanted : normal;\n        }\n\n        private boolean isOnCooldown(ItemStack item, LastEaten lastEaten) {\n            return (sharedCooldown ? lastEaten.getNewestEatTime() : lastEaten.getForItem(item))\n                    .map(it -> ChronoUnit.SECONDS.between(it, Instant.now()))\n                    .map(it -> it < getCooldownForItem(item))\n                    .orElse(false);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldArmourDurability.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport com.cryptomorin.xseries.XEnchantment;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.entity.EntityType;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport org.bukkit.event.player.PlayerItemDamageEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class ModuleOldArmourDurability extends OCMModule {\n\n    // Armour durability events can fire right after an explosion damage event. We suppress those for one tick so\n    // old-armour-durability doesn't double-apply/over-apply changes during explosion bursts.\n    //\n    // Performance/correctness:\n    // - Use a normal HashMap (WeakHashMap<UUID, ...> can drop entries unpredictably).\n    // - Avoid scheduling one task per explosion: keep a shared cleanup task that runs only while entries exist.\n    // - Entries expire after 1 tick, which is long enough for the follow-up PlayerItemDamageEvents to fire but\n    //   short enough to not interfere with unrelated armour wear later.\n    private final Map<UUID, ExplosionDamagedArmour> explosionDamaged = new HashMap<>();\n    private BukkitTask explosionCleanupTask;\n    private long explosionTickCounter;\n\n    public ModuleOldArmourDurability(OCMMain plugin) {\n        super(plugin, \"old-armour-durability\");\n    }\n\n    @EventHandler(priority = EventPriority.LOWEST)\n    public void onItemDamage(PlayerItemDamageEvent e) {\n        final Player player = e.getPlayer();\n\n        if (!isEnabled(player)) return;\n        final ItemStack item = e.getItem();\n        final Material itemType = item.getType();\n\n        // Check if it's a piece of armour they're currently wearing\n        if (Arrays.stream(player.getInventory().getArmorContents())\n                .noneMatch(armourPiece -> armourPiece != null &&\n                        armourPiece.getType() == itemType &&\n                        armourPiece.getType() != Material.ELYTRA // ignore elytra as it doesn't provide any protection anyway\n                )) return;\n\n        final UUID uuid = player.getUniqueId();\n        if (explosionDamaged.containsKey(uuid)) {\n            final ExplosionDamagedArmour data = explosionDamaged.get(uuid);\n            if (data == null) return;\n            final List<ItemStack> armour = data.armour;\n            // ItemStack.equals() checks material, durability and quantity to make sure nothing changed in the meantime\n            // We're checking all the pieces this way just in case they're wearing two helmets or something strange\n            final List<ItemStack> matchedPieces = armour.stream()\n                    .filter(piece -> piece.equals(item))\n                    .collect(Collectors.toList());\n            armour.removeAll(matchedPieces);\n            debug(\"Item matched explosion, ignoring...\", player);\n            if (!matchedPieces.isEmpty()) return;\n        }\n\n        int reduction = module().getInt(\"reduction\");\n\n        // 60 + (40 / (level + 1) ) % chance that durability is reduced (for each point of durability)\n        final int damageChance = 60 + (40 / (item.getEnchantmentLevel(XEnchantment.UNBREAKING.getEnchant()) + 1));\n        final Random random = new Random();\n        final int randomInt = random.nextInt(100); // between 0 (inclusive) and 100 (exclusive)\n        if (randomInt >= damageChance)\n            reduction = 0;\n\n        debug(\"Item damaged: \" + itemType + \" Damage: \" + e.getDamage() + \" Changed to: \" + reduction, player);\n        e.setDamage(reduction);\n    }\n\n    @EventHandler(priority = EventPriority.MONITOR)\n    public void onPlayerExplosionDamage(EntityDamageEvent e) {\n        if (e.isCancelled()) return;\n        if (e.getEntityType() != EntityType.PLAYER) return;\n        final EntityDamageEvent.DamageCause cause = e.getCause();\n        if (cause != EntityDamageEvent.DamageCause.BLOCK_EXPLOSION &&\n                cause != EntityDamageEvent.DamageCause.ENTITY_EXPLOSION) return;\n\n        final Player player = (Player) e.getEntity();\n        final UUID uuid = player.getUniqueId();\n        final List<ItemStack> armour = Arrays.stream(player.getInventory().getArmorContents()).filter(Objects::nonNull).collect(Collectors.toList());\n        explosionDamaged.put(uuid, new ExplosionDamagedArmour(armour, explosionTickCounter + 1L));\n        ensureExplosionCleanupTaskRunning();\n\n        debug(\"Detected explosion!\", player);\n    }\n\n    private void ensureExplosionCleanupTaskRunning() {\n        if (explosionCleanupTask != null) return;\n        explosionTickCounter = 0;\n\n        explosionCleanupTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {\n            explosionTickCounter++;\n            if (explosionDamaged.isEmpty()) {\n                stopExplosionCleanupTaskIfIdle();\n                return;\n            }\n\n            final Iterator<Map.Entry<UUID, ExplosionDamagedArmour>> it = explosionDamaged.entrySet().iterator();\n            while (it.hasNext()) {\n                final ExplosionDamagedArmour data = it.next().getValue();\n                if (data == null || data.expiresAtTick <= explosionTickCounter) {\n                    it.remove();\n                }\n            }\n\n            stopExplosionCleanupTaskIfIdle();\n        }, 1L, 1L);\n    }\n\n    private void stopExplosionCleanupTaskIfIdle() {\n        if (explosionCleanupTask == null) return;\n        if (!explosionDamaged.isEmpty()) return;\n        explosionCleanupTask.cancel();\n        explosionCleanupTask = null;\n    }\n\n    private static final class ExplosionDamagedArmour {\n        private final List<ItemStack> armour;\n        private final long expiresAtTick;\n\n        private ExplosionDamagedArmour(List<ItemStack> armour, long expiresAtTick) {\n            this.armour = armour;\n            this.expiresAtTick = expiresAtTick;\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldArmourStrength.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\nimport org.bukkit.event.entity.EntityDamageEvent;\n\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.stream.Collectors;\n\n/**\n * Reverts the armour strength changes to 1.8 calculations, including enchantments.\n * Also recalculates resistance and absorption accordingly.\n * <p>\n * It is based on <a href=\"https://minecraft.gamepedia.com/index.php?title=Armor&oldid=909187\">this revision</a>\n * of the minecraft wiki.\n */\npublic class ModuleOldArmourStrength extends OCMModule {\n// Defence order is armour defence points -> resistance -> armour enchants -> absorption\n\n    private boolean randomness;\n\n    public ModuleOldArmourStrength(OCMMain plugin) {\n        super(plugin, \"old-armour-strength\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        randomness = module().getBoolean(\"randomness\");\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)\n    public void onEntityDamage(EntityDamageEvent e) {\n        // 1.8 NMS: Damage = (damage after blocking * (25 - total armour strength)) / 25\n        if (!(e.getEntity() instanceof LivingEntity)) return;\n\n        final LivingEntity damagedEntity = (LivingEntity) e.getEntity();\n\n        // If there was an attacker, and he does not have this module enabled, return\n        if (e.getCause() == EntityDamageEvent.DamageCause.ENTITY_ATTACK && e instanceof EntityDamageByEntityEvent) {\n            final Entity damager = ((EntityDamageByEntityEvent) e).getDamager();\n            if(!isEnabled(damager, damagedEntity)) return;\n        }\n\n        final Map<EntityDamageEvent.DamageModifier, Double> damageModifiers =\n                Arrays.stream(EntityDamageEvent.DamageModifier.values())\n                        .filter(e::isApplicable)\n                        .collect(Collectors.toMap(m -> m, e::getDamage));\n\n        DefenceUtils.calculateDefenceDamageReduction(damagedEntity, damageModifiers, e.getCause(), randomness);\n\n        // Set the modifiers back to the event\n        damageModifiers.forEach(e::setDamage);\n\n        debug(\"BASE: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.BASE));\n        debug(\"BLOCKING: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.BLOCKING));\n        debug(\"ARMOUR: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.ARMOR));\n        debug(\"RESISTANCE: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.RESISTANCE));\n        debug(\"ARMOUR ENCHS: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.MAGIC));\n        debug(\"ABSORPTION: \" + damageModifiers.get(EntityDamageEvent.DamageModifier.ABSORPTION));\n\n        final double finalDamage = damageModifiers.values().stream().reduce(0.0, Double::sum);\n        debug(\"Final damage after defence calc: \" + finalDamage);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldBrewingStand.java",
    "content": "/*\r\n * This Source Code Form is subject to the terms of the Mozilla Public\r\n * License, v. 2.0. If a copy of the MPL was not distributed with this\r\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\r\n */\r\npackage kernitus.plugin.OldCombatMechanics.module;\r\n\r\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\r\nimport org.bukkit.Location;\r\nimport org.bukkit.block.Block;\r\nimport org.bukkit.block.BlockState;\r\nimport org.bukkit.block.BrewingStand;\r\nimport org.bukkit.event.EventHandler;\r\nimport org.bukkit.event.inventory.InventoryOpenEvent;\r\nimport org.bukkit.inventory.Inventory;\r\n\r\n/**\r\n * Makes brewing stands not require fuel.\r\n */\r\npublic class ModuleOldBrewingStand extends OCMModule {\r\n\r\n    public ModuleOldBrewingStand(OCMMain plugin) {\r\n        super(plugin, \"old-brewing-stand\");\r\n    }\r\n\r\n    @EventHandler\r\n    public void onInventoryOpen(InventoryOpenEvent e) {\r\n        // Set max fuel when they open brewing stand\r\n        // If they run out, they can just close and open it again\r\n        if (!isEnabled(e.getPlayer())) return;\r\n\r\n        final Inventory inventory = e.getInventory();\r\n        final Location location = inventory.getLocation();\r\n        if (location == null) return;\r\n\r\n        final Block block = location.getBlock();\r\n        final BlockState blockState = block.getState();\r\n\r\n        if (!(blockState instanceof BrewingStand)) return;\r\n\r\n        final BrewingStand brewingStand = (BrewingStand) blockState;\r\n\r\n        brewingStand.setFuelLevel(20);\r\n        brewingStand.update();\r\n    }\r\n}"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldBurnDelay.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.entity.EntityDamageEvent;\n\n/**\n * Bring back old fire burning delay behaviour\n */\npublic class ModuleOldBurnDelay extends OCMModule {\n\n    private int fireTicks;\n\n    public ModuleOldBurnDelay(OCMMain plugin) {\n        super(plugin, \"old-burn-delay\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        fireTicks = module().getInt(\"fire-ticks\");\n    }\n\n    @EventHandler\n    public void onFireTick(EntityDamageEvent e) {\n        if (e.getCause() == EntityDamageEvent.DamageCause.FIRE) {\n            final Entity entity = e.getEntity();\n            if(!isEnabled(entity)) return;\n\n            entity.setFireTicks(fireTicks);\n            debug(\"Setting fire ticks to \" + fireTicks, entity);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldCriticalHits.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.event.EventHandler;\n\npublic class ModuleOldCriticalHits extends OCMModule {\n\n    private boolean allowSprinting;\n    private double multiplier;\n\n    public ModuleOldCriticalHits(OCMMain plugin) {\n        super(plugin, \"old-critical-hits\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        allowSprinting = module().getBoolean(\"allowSprinting\", true);\n        multiplier = module().getDouble(\"multiplier\", 1.5);\n    }\n\n    @EventHandler\n    public void onOCMDamage(OCMEntityDamageByEntityEvent e) {\n        if (!isEnabled(e.getDamager(), e.getDamagee())) return;\n\n        boolean isCritical = e.was1_8Crit();\n        if (!isCritical && e.getDamager() instanceof HumanEntity) {\n            isCritical = DamageUtils.isCriticalHit1_8((HumanEntity) e.getDamager());\n        }\n\n        // In 1.9, a critical hit requires the player not to be sprinting\n        if (isCritical && (allowSprinting || !e.wasSprinting()))\n            e.setCriticalMultiplier(multiplier);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldPotionEffects.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionDurations;\nimport com.cryptomorin.xseries.XPotion;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionKey;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionEffects;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.WeaknessCompensation;\nimport org.bukkit.Material;\nimport org.bukkit.World;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.Event;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.HandlerList;\nimport org.bukkit.event.Listener;\nimport org.bukkit.plugin.EventExecutor;\nimport org.bukkit.event.block.Action;\nimport org.bukkit.event.block.BlockDispenseEvent;\nimport org.bukkit.event.player.PlayerInteractEvent;\nimport org.bukkit.event.player.PlayerItemConsumeEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.inventory.meta.PotionMeta;\nimport org.bukkit.potion.PotionData;\nimport org.bukkit.potion.PotionEffect;\nimport org.bukkit.potion.PotionEffectType;\nimport org.bukkit.potion.PotionType;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.lang.reflect.Method;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\n\n/**\n * Allows configurable potion effect durations.\n */\npublic class ModuleOldPotionEffects extends OCMModule {\n    private static final Set<String> NON_EFFECT_POTION_TYPES = Collections.unmodifiableSet(\n            new HashSet<>(Arrays.asList(\n                    \"AWKWARD\",\n                    \"MUNDANE\",\n                    \"THICK\",\n                    \"WATER\",\n                    \"UNCRAFTABLE\",\n                    \"HARMING\",\n                    \"STRONG_HARMING\",\n                    \"HEALING\",\n                    \"STRONG_HEALING\",\n                    \"INSTANT_DAMAGE\",\n                    \"INSTANT_HEAL\",\n                    \"INSTANT_HEALTH\"\n            )));\n\n    private Map<PotionKey, PotionDurations> durations;\n    private final Set<String> warnedUnknownPotionTypes = new HashSet<>();\n    private boolean weaknessAmplifierClamped;\n    private boolean potionEffectListenerAttempted;\n    private boolean potionEffectListenerBroken;\n    private Listener potionEffectListener;\n    private Method potionEffectGetEntity;\n    private Method potionEffectGetNewEffect;\n    private Method potionEffectGetOldEffect;\n    private static final String ENTITY_POTION_EFFECT_EVENT = \"org.bukkit.event.entity.EntityPotionEffectEvent\";\n\n    public ModuleOldPotionEffects(OCMMain plugin) {\n        super(plugin, \"old-potion-effects\");\n\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        durations = ConfigUtils.loadPotionDurationsList(module());\n        weaknessAmplifierClamped = detectWeaknessAmplifierClamp();\n        syncWeaknessCompensation();\n        updatePotionEffectListener();\n    }\n\n    /**\n     * Change the duration using values defined in config for drinking potions\n     */\n    @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST)\n    public void onPlayerDrinksPotion(PlayerItemConsumeEvent event) {\n        final Player player = event.getPlayer();\n        if (!isEnabled(player)) return;\n\n        final ItemStack potionItem = event.getItem();\n        if (potionItem.getType() != Material.POTION) return;\n\n        adjustPotion(potionItem, false);\n        event.setItem(potionItem);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\n    public void onPotionDispense(BlockDispenseEvent event) {\n        if (!isEnabled(event.getBlock().getWorld())) return;\n\n        final ItemStack item = event.getItem();\n        final Material material = item.getType();\n\n        if (material == Material.SPLASH_POTION || material == Material.LINGERING_POTION)\n            adjustPotion(item, true);\n    }\n\n    // We change the potion on-the-fly just as it's thrown to be able to change the effect\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPotionThrow(PlayerInteractEvent event) {\n        final Player player = event.getPlayer();\n        if (!isEnabled(player)) return;\n\n        final Action action = event.getAction();\n        if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;\n\n        final ItemStack item = event.getItem();\n        if (item == null) return;\n\n        final Material material = item.getType();\n        if (material == Material.SPLASH_POTION || material == Material.LINGERING_POTION)\n            adjustPotion(item, true);\n    }\n\n    @Override\n    public void onModesetChange(Player player) {\n        if (!weaknessAmplifierClamped) {\n            WeaknessCompensation.remove(player);\n            return;\n        }\n        applyWeaknessCompensation(player);\n    }\n\n    private void updatePotionEffectListener() {\n        if (!weaknessAmplifierClamped) {\n            unregisterPotionEffectListener();\n            return;\n        }\n\n        ensurePotionEffectListener();\n    }\n\n    private void ensurePotionEffectListener() {\n        if (potionEffectListenerAttempted || potionEffectListenerBroken) return;\n        // EntityPotionEffectEvent exists from 1.13 onwards, and from ~1.20 NMS clamps\n        // Weakness amplifiers to non-negative values which breaks old-damage detection.\n        // Use feature detection (class presence + behaviour checks) rather than version\n        // numbers because some servers backport these APIs/behaviours.\n        potionEffectListenerAttempted = true;\n        final Class<?> eventClass = resolveEntityPotionEffectEvent();\n        if (eventClass == null) {\n            potionEffectListenerAttempted = false;\n            return;\n        }\n\n        try {\n            potionEffectGetEntity = eventClass.getMethod(\"getEntity\");\n            potionEffectGetNewEffect = eventClass.getMethod(\"getNewEffect\");\n            potionEffectGetOldEffect = eventClass.getMethod(\"getOldEffect\");\n        } catch (NoSuchMethodException e) {\n            Messenger.warn(\"[%s] Unable to resolve EntityPotionEffectEvent accessors; weakness compensation is disabled.\",\n                    getModuleName());\n            potionEffectListenerBroken = true;\n            return;\n        }\n\n        potionEffectListener = new Listener() {};\n        @SuppressWarnings(\"unchecked\")\n        final Class<? extends Event> typedEvent = (Class<? extends Event>) eventClass;\n        plugin.getServer().getPluginManager().registerEvent(\n                typedEvent,\n                potionEffectListener,\n                EventPriority.MONITOR,\n                new EventExecutor() {\n                    @Override\n                    public void execute(Listener listener, Event event) {\n                        handleEntityPotionEffectEvent(event);\n                    }\n                },\n                plugin,\n                true\n        );\n    }\n\n    private void unregisterPotionEffectListener() {\n        if (potionEffectListener == null) return;\n        HandlerList.unregisterAll(potionEffectListener);\n        potionEffectListener = null;\n        potionEffectListenerAttempted = false;\n        potionEffectListenerBroken = false;\n        potionEffectGetEntity = null;\n        potionEffectGetNewEffect = null;\n        potionEffectGetOldEffect = null;\n    }\n\n    private Class<?> resolveEntityPotionEffectEvent() {\n        try {\n            return Class.forName(ENTITY_POTION_EFFECT_EVENT, false, ModuleOldPotionEffects.class.getClassLoader());\n        } catch (ClassNotFoundException e) {\n            return null;\n        }\n    }\n\n    private void handleEntityPotionEffectEvent(Event event) {\n        if (potionEffectListenerBroken || !weaknessAmplifierClamped) return;\n\n        final Entity entity = extractEntity(event);\n        if (!(entity instanceof LivingEntity)) return;\n        final LivingEntity livingEntity = (LivingEntity) entity;\n\n        if (!isEnabled(livingEntity)) {\n            WeaknessCompensation.remove(livingEntity);\n            return;\n        }\n\n        final PotionEffect newEffect = extractPotionEffect(event, potionEffectGetNewEffect);\n        final PotionEffect oldEffect = extractPotionEffect(event, potionEffectGetOldEffect);\n        final PotionEffectType type = newEffect != null ? newEffect.getType() :\n                (oldEffect != null ? oldEffect.getType() : null);\n        final PotionEffectType weakness = XPotion.WEAKNESS.get();\n        if (type == null || weakness == null || !type.equals(weakness)) return;\n\n        if (newEffect != null && newEffect.getAmplifier() >= 0) {\n            WeaknessCompensation.apply(livingEntity);\n        } else {\n            WeaknessCompensation.remove(livingEntity);\n        }\n    }\n\n    private Entity extractEntity(Event event) {\n        if (potionEffectListenerBroken || potionEffectGetEntity == null) return null;\n        try {\n            return (Entity) potionEffectGetEntity.invoke(event);\n        } catch (ReflectiveOperationException e) {\n            potionEffectListenerBroken = true;\n            Messenger.warn(\"[%s] Failed to read EntityPotionEffectEvent entity; weakness compensation is disabled.\",\n                    getModuleName());\n            return null;\n        }\n    }\n\n    private PotionEffect extractPotionEffect(Event event, Method accessor) {\n        if (potionEffectListenerBroken || accessor == null) return null;\n        try {\n            return (PotionEffect) accessor.invoke(event);\n        } catch (ReflectiveOperationException e) {\n            potionEffectListenerBroken = true;\n            Messenger.warn(\"[%s] Failed to read EntityPotionEffectEvent effect; weakness compensation is disabled.\",\n                    getModuleName());\n            return null;\n        }\n    }\n\n    /**\n     * Sets custom potion duration and effects\n     *\n     * @param potionItem The potion item with adjusted duration and effects\n     */\n    private void adjustPotion(ItemStack potionItem, boolean splash) {\n        final PotionMeta potionMeta = (PotionMeta) potionItem.getItemMeta();\n        if (potionMeta == null) return;\n\n        PotionType potionType;\n        String potionTypeName;\n        try {\n            potionType = potionMeta.getBasePotionType();\n            if (potionType == null) return;\n            potionTypeName = potionType.name();\n        } catch (NoSuchMethodError e) {\n            potionType = potionMeta.getBasePotionData().getType();\n            potionTypeName = potionType.name();\n        }\n\n        final PotionKey potionKey = PotionKey.fromPotionMeta(potionMeta).orElse(null);\n        if (potionKey == null) {\n            if (!NON_EFFECT_POTION_TYPES.contains(potionTypeName) && warnedUnknownPotionTypes.add(potionTypeName)) {\n                Messenger.warn(\"[%s] Unknown potion type '%s' encountered; old-potion-effects will not adjust it\",\n                        getModuleName(), potionTypeName);\n            }\n            return;\n        }\n\n        final Integer duration = getPotionDuration(potionKey, splash);\n        if (duration == null) {\n            debug(\"Potion type \" + potionKey.getDebugName() + \" not found in config, leaving as is...\");\n            return;\n        }\n\n        int amplifier = potionKey.isStrong() ? 1 : 0;\n\n        if (potionKey.isPotion(XPotion.WEAKNESS)) {\n            // Set level to 0 so that it doesn't prevent the EntityDamageByEntityEvent from being called\n            // due to damage being lower than 0 as some 1.9 weapons deal less damage\n            amplifier = -1;\n        }\n\n        List<PotionEffectType> potionEffects;\n        try {\n            potionEffects = potionType.getPotionEffects().stream()\n                    .map(PotionEffect::getType)\n                    .collect(Collectors.toList());\n        } catch (NoSuchMethodError e) {\n            potionEffects = Collections.singletonList(potionType.getEffectType());\n        }\n\n        for (PotionEffectType effectType : potionEffects) {\n            potionMeta.addCustomEffect(new PotionEffect(effectType, duration, amplifier), false);\n        }\n\n        try { // For >=1.20\n            potionMeta.setBasePotionType(PotionType.WATER);\n        } catch (NoSuchMethodError e) {\n            potionMeta.setBasePotionData(new PotionData(PotionType.WATER));\n        }\n\n        potionItem.setItemMeta(potionMeta);\n    }\n\n\n    @EventHandler(ignoreCancelled = true)\n    public void onDamageByEntity(OCMEntityDamageByEntityEvent event) {\n        final Entity damager = event.getDamager();\n        if (!isEnabled(damager, event.getDamagee())) return;\n\n        if (event.hasWeakness()) {\n            event.setIsWeaknessModifierMultiplier(module().getBoolean(\"weakness.multiplier\"));\n            final double newWeaknessModifier = module().getDouble(\"weakness.modifier\");\n            event.setWeaknessModifier(newWeaknessModifier);\n            event.setWeaknessLevel(1);\n            debug(\"Old weakness modifier: \" + event.getWeaknessLevel() +\n                    \" New: \" + newWeaknessModifier, damager);\n        }\n\n        final double strengthModifier = event.getStrengthModifier();\n\n        if (strengthModifier > 0) {\n            event.setIsStrengthModifierMultiplier(module().getBoolean(\"strength.multiplier\"));\n            event.setIsStrengthModifierAddend(module().getBoolean(\"strength.addend\"));\n            final double newStrengthModifier = module().getDouble(\"strength.modifier\");\n            event.setStrengthModifier(newStrengthModifier);\n            debug(\"Old strength modifier: \" + strengthModifier + \" New: \" + newStrengthModifier, damager);\n        }\n    }\n\n    private Integer getPotionDuration(PotionKey potionKey, boolean splash) {\n        final PotionDurations potionDurations = durations.get(potionKey);\n        if (potionDurations == null) return null;\n        final int duration = splash ? potionDurations.splash() : potionDurations.drinkable();\n\n        debug(\"Potion type: \" + potionKey.getDebugName() + \" Duration: \" + duration + \" ticks\");\n\n        return duration;\n    }\n\n    private boolean detectWeaknessAmplifierClamp() {\n        try {\n            final PotionEffectType weakness = XPotion.WEAKNESS.get();\n            if (weakness == null) return false;\n            final ItemStack item = new ItemStack(Material.POTION);\n            final PotionMeta meta = (PotionMeta) item.getItemMeta();\n            if (meta == null) return false;\n            meta.addCustomEffect(new PotionEffect(weakness, 20, -1), true);\n            item.setItemMeta(meta);\n            final PotionMeta updated = (PotionMeta) item.getItemMeta();\n            if (updated == null) return false;\n            final PotionEffect effect = updated.getCustomEffects().stream()\n                    .filter(potionEffect -> potionEffect.getType().equals(weakness))\n                    .findFirst()\n                    .orElse(null);\n            return effect != null && effect.getAmplifier() != -1;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private void syncWeaknessCompensation() {\n        if (!weaknessAmplifierClamped) {\n            removeWeaknessCompensation();\n            return;\n        }\n\n        for (World world : plugin.getServer().getWorlds()) {\n            for (LivingEntity entity : world.getLivingEntities()) {\n                applyWeaknessCompensation(entity);\n            }\n        }\n    }\n\n    private void removeWeaknessCompensation() {\n        for (World world : plugin.getServer().getWorlds()) {\n            for (LivingEntity entity : world.getLivingEntities()) {\n                WeaknessCompensation.remove(entity);\n            }\n        }\n    }\n\n    private void applyWeaknessCompensation(LivingEntity entity) {\n        if (!isEnabled(entity)) {\n            WeaknessCompensation.remove(entity);\n            return;\n        }\n        final PotionEffectType weakness = XPotion.WEAKNESS.get();\n        if (weakness == null) return;\n        final PotionEffect effect = PotionEffects.getOrNull(entity, weakness);\n        if (effect != null && effect.getAmplifier() >= 0) {\n            WeaknessCompensation.apply(entity);\n        } else {\n            WeaknessCompensation.remove(entity);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleOldToolDamage.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages;\nimport org.bukkit.Bukkit;\nimport org.bukkit.ChatColor;\nimport org.bukkit.Material;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.Listener;\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport org.bukkit.inventory.ItemStack;\n\nimport java.math.BigDecimal;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.stream.Collectors;\n\n/**\n * Restores old tool damage.\n */\npublic class ModuleOldToolDamage extends OCMModule {\n\n    private static final String[] WEAPONS = {\"sword\", \"axe\", \"pickaxe\", \"spade\", \"shovel\", \"hoe\"};\n    private static final Class<?> TRIDENT_CLASS;\n    private static final boolean HAS_TRIDENT;\n    private boolean oldSharpness;\n    private boolean tooltipEnabled;\n    private String tooltipPrefix;\n    private final TooltipListener tooltipListener;\n\n    static {\n        Class<?> tridentClass = null;\n        boolean hasTrident = false;\n        try {\n            tridentClass = Class.forName(\"org.bukkit.entity.Trident\");\n            hasTrident = true;\n        } catch (ClassNotFoundException ignored) {\n            // Legacy servers (e.g. 1.9/1.12) do not have tridents.\n        }\n        TRIDENT_CLASS = tridentClass;\n        HAS_TRIDENT = hasTrident;\n    }\n\n    public ModuleOldToolDamage(OCMMain plugin) {\n        super(plugin, \"old-tool-damage\");\n        tooltipListener = new TooltipListener();\n        Bukkit.getPluginManager().registerEvents(tooltipListener, plugin);\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        oldSharpness = module().getBoolean(\"old-sharpness\", true);\n        tooltipEnabled = module().getBoolean(\"tooltip.enabled\", false);\n        tooltipPrefix = module().getString(\"tooltip.prefix\", \"OCM Damage:\");\n        if (tooltipPrefix == null || tooltipPrefix.trim().isEmpty()) {\n            tooltipPrefix = \"OCM Damage:\";\n        }\n\n        // Update online players so config changes take effect immediately\n        Bukkit.getOnlinePlayers().forEach(tooltipListener::applyToHeld);\n    }\n\n    @EventHandler(ignoreCancelled = true)\n    public void onEntityDamaged(OCMEntityDamageByEntityEvent event) {\n        final Entity damager = event.getDamager();\n        if (event.getCause() == EntityDamageEvent.DamageCause.THORNS) return;\n\n        if (!isEnabled(damager, event.getDamagee())) return;\n\n        final ItemStack weapon = event.getWeapon();\n        final Material weaponMaterial = weapon.getType();\n        final String weaponName = weaponMaterial.name();\n        debug(\"Weapon material: \" + weaponMaterial);\n\n        if (!isWeapon(weaponMaterial)) return;\n\n        final double newWeaponBaseDamage = WeaponDamages.getDamage(weaponMaterial);\n        if (newWeaponBaseDamage <= 0) {\n            debug(\"Unknown tool type: \" + weaponMaterial, damager);\n            return;\n        }\n\n        final double oldBaseDamage = event.getBaseDamage();\n        final Float expectedBaseDamage = NewWeaponDamage.getDamageOrNull(weaponMaterial);\n        if (damager instanceof org.bukkit.entity.HumanEntity) {\n            boolean isMace = weaponName.equals(\"MACE\");\n            double adjustedBase = newWeaponBaseDamage;\n\n            if (expectedBaseDamage != null) {\n                final double diff = oldBaseDamage - expectedBaseDamage;\n                // For mace we treat diff as the vanilla fall bonus and preserve it.\n                if (isMace) {\n                    adjustedBase += diff;\n                } else {\n                    // We check difference as calculation inaccuracies can make it not match\n                    if (Math.abs(diff) > 0.0001) {\n                        debug(\"Expected \" + expectedBaseDamage + \" got \" + oldBaseDamage + \" ignoring weapon...\");\n                        return;\n                    }\n                }\n            } else {\n                debug(\"No baseline damage for \" + weaponMaterial + \", applying configured damage.\", damager);\n            }\n\n            event.setBaseDamage(adjustedBase);\n            Messenger.debug(\"Old tool damage: \" + oldBaseDamage + \" New: \" + adjustedBase);\n        } else if (damager instanceof org.bukkit.entity.LivingEntity) {\n            if (expectedBaseDamage == null) {\n                debug(\"No baseline damage for \" + weaponMaterial + \", ignoring mob weapon.\", damager);\n                return;\n            }\n\n            // Mobs do not have a reliable baseline check like players, so we always apply the delta.\n            // This means custom mob weapons are not detected and will still be shifted, which may\n            // interact poorly with other plugins that modify mob damage in non-vanilla ways.\n            final double delta = newWeaponBaseDamage - expectedBaseDamage;\n            final double newBaseDamage = oldBaseDamage + delta;\n            event.setBaseDamage(newBaseDamage);\n            Messenger.debug(\"Old tool damage (mob): \" + oldBaseDamage + \" New: \" + newBaseDamage);\n        }\n\n\n        // Set sharpness to 1.8 damage value\n        final int sharpnessLevel = event.getSharpnessLevel();\n        double newSharpnessDamage = oldSharpness ?\n                DamageUtils.getOldSharpnessDamage(sharpnessLevel) :\n                DamageUtils.getNewSharpnessDamage(sharpnessLevel);\n\n        debug(\"Old sharpness damage: \" + event.getSharpnessDamage() + \" New: \" + newSharpnessDamage, damager);\n        event.setSharpnessDamage(newSharpnessDamage);\n\n        // The mob enchantments damage remains the same and is linear, no need to recalculate it\n    }\n\n    private boolean isWeapon(Material material) {\n        final String name = material.name();\n        if (name.equals(\"TRIDENT\") || name.equals(\"MACE\")) return true;\n        return Arrays.stream(WEAPONS).anyMatch(type -> isOfType(material, type));\n    }\n\n    private boolean isOfType(Material mat, String type) {\n        return mat.toString().endsWith(\"_\" + type.toUpperCase(Locale.ROOT));\n    }\n\n    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)\n    public void onTridentProjectile(org.bukkit.event.entity.EntityDamageByEntityEvent event) {\n        if (!HAS_TRIDENT || !TRIDENT_CLASS.isInstance(event.getDamager())) return;\n        if (!isEnabled(event.getDamager(), event.getEntity())) return;\n\n        final double configured = WeaponDamages.getDamage(\"TRIDENT_THROWN\");\n        if (configured <= 0) return;\n\n        event.setDamage(configured);\n        debug(\"Applied custom thrown trident damage: \" + configured, event.getDamager());\n    }\n\n    private boolean shouldApplyTooltip(Player player) {\n        if (!tooltipEnabled) return false;\n        return isEnabled(player);\n    }\n\n    private String formatDamage(double value) {\n        if (value == (long) value) {\n            return Long.toString((long) value);\n        }\n        return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();\n    }\n\n    private List<String> removeExistingTooltip(List<String> lore) {\n        final String needle = tooltipPrefix;\n        if (needle == null || needle.trim().isEmpty()) {\n            return lore;\n        }\n        return lore.stream()\n                .filter(line -> {\n                    final String stripped = ChatColor.stripColor(line);\n                    return stripped == null || !stripped.startsWith(needle);\n                })\n                .collect(Collectors.toList());\n    }\n\n    private void applyTooltip(Player player, ItemStack item) {\n        if (item == null || item.getType() == Material.AIR) return;\n        if (!isWeapon(item.getType())) {\n            stripTooltip(item);\n            return;\n        }\n        if (!shouldApplyTooltip(player)) {\n            stripTooltip(item);\n            return;\n        }\n\n        final double configured = WeaponDamages.getDamage(item.getType());\n        if (configured <= 0) {\n            stripTooltip(item);\n            return;\n        }\n\n        final org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta();\n        if (meta == null) return;\n        final List<String> base = meta.getLore() == null ? new java.util.ArrayList<>() : removeExistingTooltip(meta.getLore());\n        base.add(ChatColor.BLUE + tooltipPrefix + \" \" + formatDamage(configured));\n        meta.setLore(base);\n        item.setItemMeta(meta);\n    }\n\n    private void stripTooltip(ItemStack item) {\n        if (item == null || item.getType() == Material.AIR) return;\n        final org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta();\n        if (meta == null || meta.getLore() == null) return;\n        final List<String> updated = removeExistingTooltip(meta.getLore());\n        if (updated.size() == meta.getLore().size()) return;\n        if (updated.isEmpty()) {\n            meta.setLore(null);\n        } else {\n            meta.setLore(updated);\n        }\n        item.setItemMeta(meta);\n    }\n\n    /**\n     * Always-on listener that keeps the tooltip line in sync with the held item, and strips it when the module is\n     * disabled or the item leaves hand.\n     */\n    private class TooltipListener implements Listener {\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onJoin(org.bukkit.event.player.PlayerJoinEvent event) {\n            applyToHeld(event.getPlayer());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onHotbar(org.bukkit.event.player.PlayerItemHeldEvent event) {\n            cleanHand(event.getPlayer(), event.getPreviousSlot());\n            applyToHeld(event.getPlayer());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onSwap(org.bukkit.event.player.PlayerSwapHandItemsEvent event) {\n            stripTooltip(event.getMainHandItem());\n            stripTooltip(event.getOffHandItem());\n            applyTooltip(event.getPlayer(), event.getOffHandItem()); // new main hand after swap\n            stripTooltip(event.getMainHandItem()); // new offhand should stay clean\n        }\n\n        void applyToHeld(Player player) {\n            final ItemStack item = player.getInventory().getItemInMainHand();\n            applyTooltip(player, item);\n        }\n\n        private void cleanHand(Player player, int slot) {\n            final ItemStack old = player.getInventory().getItem(slot);\n            stripTooltip(old);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModulePlayerKnockback.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.cryptomorin.xseries.XAttribute;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Location;\nimport org.bukkit.Material;\nimport org.bukkit.attribute.AttributeInstance;\nimport com.cryptomorin.xseries.XEnchantment;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.event.player.PlayerVelocityEvent;\nimport org.bukkit.inventory.EntityEquipment;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.scheduler.BukkitTask;\nimport org.bukkit.util.Vector;\n\nimport java.util.HashMap;\nimport java.util.Iterator;\nimport java.util.Map;\nimport java.util.UUID;\n\n/**\n * Reverts knockback formula to 1.8.\n * Also disables netherite knockback resistance.\n */\npublic class ModulePlayerKnockback extends OCMModule {\n\n    private double knockbackHorizontal;\n    private double knockbackVertical;\n    private double knockbackVerticalLimit;\n    private double knockbackExtraHorizontal;\n    private double knockbackExtraVertical;\n    private boolean netheriteKnockbackResistance;\n\n    // Knockback override for the next PlayerVelocityEvent.\n    // Performance/correctness:\n    // - Use a normal HashMap (WeakHashMap can drop entries unpredictably).\n    // - Keep entries for at most 1 tick. If PlayerVelocityEvent does not fire, a stale entry must not affect\n    //   a later, unrelated velocity event (explosions, plugins, etc.).\n    // - Avoid scheduling one task per hit: we run one shared cleanup task only while there is anything pending.\n    private final Map<UUID, PendingKnockback> pendingKnockback = new HashMap<>();\n    private BukkitTask pendingCleanupTask;\n    private long pendingTickCounter;\n\n    public ModulePlayerKnockback(OCMMain plugin) {\n        super(plugin, \"old-player-knockback\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        knockbackHorizontal = module().getDouble(\"knockback-horizontal\", 0.4);\n        knockbackVertical = module().getDouble(\"knockback-vertical\", 0.4);\n        knockbackVerticalLimit = module().getDouble(\"knockback-vertical-limit\", 0.4);\n        knockbackExtraHorizontal = module().getDouble(\"knockback-extra-horizontal\", 0.5);\n        knockbackExtraVertical = module().getDouble(\"knockback-extra-vertical\", 0.1);\n        netheriteKnockbackResistance = module().getBoolean(\"enable-knockback-resistance\", false)\n                && Reflector.versionIsNewerOrEqualTo(1, 16, 0);\n    }\n\n    @EventHandler\n    public void onPlayerQuit(PlayerQuitEvent e) {\n        pendingKnockback.remove(e.getPlayer().getUniqueId());\n        stopCleanupTaskIfIdle();\n    }\n\n    // Vanilla does its own knockback, so we need to set it again.\n    // priority = lowest because we are ignoring the existing velocity, which could\n    // break other plugins\n    @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)\n    public void onPlayerVelocityEvent(PlayerVelocityEvent event) {\n        final UUID uuid = event.getPlayer().getUniqueId();\n        final PendingKnockback pending = pendingKnockback.remove(uuid);\n        if (pending == null) return;\n        event.setVelocity(pending.velocity);\n        stopCleanupTaskIfIdle();\n    }\n\n    @EventHandler\n    public void onEntityDamage(EntityDamageEvent event) {\n        // Disable netherite kb, the knockback resistance attribute makes the velocity\n        // event not be called\n        final Entity entity = event.getEntity();\n        if (!(entity instanceof Player) || netheriteKnockbackResistance)\n            return;\n        final Player damagee = (Player) entity;\n\n        // This depends on the attacker's combat mode\n        if (event.getCause() == EntityDamageEvent.DamageCause.ENTITY_ATTACK\n                && event instanceof EntityDamageByEntityEvent) {\n            final Entity damager = ((EntityDamageByEntityEvent) event).getDamager();\n            if (!isEnabled(damager))\n                return;\n        } else {\n            if (!isEnabled(damagee))\n                return;\n        }\n\n        final AttributeInstance attribute = damagee.getAttribute(XAttribute.KNOCKBACK_RESISTANCE.get());\n        attribute.getModifiers().forEach(attribute::removeModifier);\n    }\n\n    // Monitor priority because we don't modify anything here, but apply on velocity\n    // change event\n    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n    public void onEntityDamageEntity(EntityDamageByEntityEvent event) {\n        final Entity damager = event.getDamager();\n        if (!(damager instanceof LivingEntity))\n            return;\n        final LivingEntity attacker = (LivingEntity) damager;\n\n        final Entity damagee = event.getEntity();\n        if (!(damagee instanceof Player))\n            return;\n        final Player victim = (Player) damagee;\n\n        if (event.getCause() != EntityDamageEvent.DamageCause.ENTITY_ATTACK)\n            return;\n        if (event.getDamage(EntityDamageEvent.DamageModifier.BLOCKING) > 0)\n            return;\n\n        if (attacker instanceof HumanEntity) {\n            if (!isEnabled(attacker))\n                return;\n        } else if (!isEnabled(victim))\n            return;\n\n        // Figure out base knockback direction\n        Location attackerLocation = attacker.getLocation();\n        Location victimLocation = victim.getLocation();\n        double d0 = attackerLocation.getX() - victimLocation.getX();\n        double d1;\n\n        for (d1 = attackerLocation.getZ() - victimLocation.getZ(); d0 * d0\n                + d1 * d1 < 1.0E-4D; d1 = (Math.random() - Math.random()) * 0.01D) {\n            d0 = (Math.random() - Math.random()) * 0.01D;\n        }\n\n        final double magnitude = Math.sqrt(d0 * d0 + d1 * d1);\n\n        // Get player knockback before any friction is applied\n        final Vector playerVelocity = victim.getVelocity();\n\n        // Apply friction, then add base knockback\n        playerVelocity.setX((playerVelocity.getX() / 2) - (d0 / magnitude * knockbackHorizontal));\n        playerVelocity.setY((playerVelocity.getY() / 2) + knockbackVertical);\n        playerVelocity.setZ((playerVelocity.getZ() / 2) - (d1 / magnitude * knockbackHorizontal));\n\n        // Calculate bonus knockback for sprinting or knockback enchantment levels\n        final EntityEquipment equipment = attacker.getEquipment();\n        if (equipment != null) {\n            final ItemStack heldItem = equipment.getItemInMainHand().getType() == Material.AIR\n                    ? equipment.getItemInOffHand()\n                    : equipment.getItemInMainHand();\n\n            int bonusKnockback;\n            if (XEnchantment.KNOCKBACK.getEnchant() == null) {\n                bonusKnockback = 0;\n            } else {\n                bonusKnockback = heldItem.getEnchantmentLevel(XEnchantment.KNOCKBACK.getEnchant());\n            }\n            if (attacker instanceof Player && ((Player) attacker).isSprinting()) {\n                bonusKnockback++;\n            }\n\n            if (playerVelocity.getY() > knockbackVerticalLimit)\n                playerVelocity.setY(knockbackVerticalLimit);\n\n            if (bonusKnockback > 0) { // Apply bonus knockback\n                playerVelocity.add(new Vector((-Math.sin(attacker.getLocation().getYaw() * 3.1415927F / 180.0F) *\n                        (float) bonusKnockback * knockbackExtraHorizontal), knockbackExtraVertical,\n                        Math.cos(attacker.getLocation().getYaw() * 3.1415927F / 180.0F) *\n                                (float) bonusKnockback * knockbackExtraHorizontal));\n            }\n        }\n\n        if (netheriteKnockbackResistance) {\n            // Allow netherite to affect the horizontal knockback. Each piece of armour\n            // yields 10% resistance\n            final double resistance = 1 - victim.getAttribute(XAttribute.KNOCKBACK_RESISTANCE.get()).getValue();\n            playerVelocity.multiply(new Vector(resistance, 1, resistance));\n        }\n\n        final UUID victimId = victim.getUniqueId();\n\n        // Knockback is sent immediately in 1.8+, there is no reason to send packets\n        // manually\n        pendingKnockback.put(victimId, new PendingKnockback(playerVelocity, pendingTickCounter + 1));\n        ensureCleanupTaskRunning();\n    }\n\n    private void ensureCleanupTaskRunning() {\n        if (pendingCleanupTask != null) return;\n        pendingTickCounter = 0;\n\n        // Delay by 1 tick so we never expire entries in the same tick they were created.\n        pendingCleanupTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {\n            pendingTickCounter++;\n            if (pendingKnockback.isEmpty()) {\n                stopCleanupTaskIfIdle();\n                return;\n            }\n\n            final Iterator<Map.Entry<UUID, PendingKnockback>> it = pendingKnockback.entrySet().iterator();\n            while (it.hasNext()) {\n                final PendingKnockback pending = it.next().getValue();\n                if (pending == null || pending.expiresAtTick <= pendingTickCounter) {\n                    it.remove();\n                }\n            }\n\n            stopCleanupTaskIfIdle();\n        }, 1L, 1L);\n    }\n\n    private void stopCleanupTaskIfIdle() {\n        if (pendingCleanupTask == null) return;\n        if (!pendingKnockback.isEmpty()) return;\n        pendingCleanupTask.cancel();\n        pendingCleanupTask = null;\n    }\n\n    private static final class PendingKnockback {\n        private final Vector velocity;\n        private final long expiresAtTick;\n\n        private PendingKnockback(Vector velocity, long expiresAtTick) {\n            // Defensive clone: callers may re-use/mutate the Vector instance.\n            this.velocity = velocity == null ? new Vector() : velocity.clone();\n            this.expiresAtTick = expiresAtTick;\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModulePlayerRegen.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.cryptomorin.xseries.XAttribute;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.MathsHelper;\nimport org.bukkit.Bukkit;\nimport org.bukkit.attribute.Attribute;\nimport org.bukkit.entity.EntityType;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityRegainHealthEvent;\nimport org.bukkit.event.player.PlayerQuitEvent;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.UUID;\n\n/**\n * Establishes custom health regeneration rules.\n * Default values based on 1.8 from\n * <a href=\"https://minecraft.gamepedia.com/Hunger?oldid=948685\">wiki</a>\n */\npublic class ModulePlayerRegen extends OCMModule {\n\n    // Vanilla 1.8 natural regen is driven by ticks (foodTickTimer reaches 80 ticks), not wall-clock time.\n    // We therefore measure \"interval\" in ticks so behaviour stays consistent with TPS drops, rather than\n    // speeding up/slowing down based on real time.\n    //\n    // Performance/correctness:\n    // - Use a normal HashMap (WeakHashMap<UUID, ...> can drop entries unpredictably).\n    // - Keep a single shared tick counter task that runs only while we are tracking at least one player, rather\n    //   than any per-player repeating tasks.\n    private final Map<UUID, Long> lastHealTick = new HashMap<>();\n    private BukkitTask tickTask;\n    private long tickCounter;\n    private long intervalTicks;\n    private int healAmount;\n    private float exhaustionToApply;\n\n    public ModulePlayerRegen(OCMMain plugin) {\n        super(plugin, \"old-player-regen\");\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        final long intervalMillis = module().getLong(\"interval\");\n        // Config is in milliseconds for user friendliness, but internal logic is tick based.\n        intervalTicks = Math.max(1L, Math.round(intervalMillis / 50.0));\n        healAmount = module().getInt(\"amount\");\n        exhaustionToApply = (float) module().getDouble(\"exhaustion\");\n\n        if (tickTask != null && lastHealTick.isEmpty()) {\n            tickTask.cancel();\n            tickTask = null;\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)\n    public void onRegen(EntityRegainHealthEvent e) {\n        if (e.getEntityType() != EntityType.PLAYER\n                || e.getRegainReason() != EntityRegainHealthEvent.RegainReason.SATIATED)\n            return;\n\n        final Player p = (Player) e.getEntity();\n        if (!isEnabled(p))\n            return;\n\n        final UUID playerId = p.getUniqueId();\n\n        // We cancel the regen, but saturation and exhaustion need to be adjusted\n        // separately\n        // Exhaustion is modified in the next tick, and saturation in the tick following\n        // that (if exhaustion > 4)\n        e.setCancelled(true);\n\n        // Get exhaustion & saturation values before healing modifies them\n        final float previousExhaustion = p.getExhaustion();\n        final float previousSaturation = p.getSaturation();\n\n        ensureTickTaskRunning();\n\n        // Check that it has been at least x ticks since last heal\n        final long currentTick = tickCounter;\n        final Long lastTick = lastHealTick.get(playerId);\n        debug(\"Exh: \" + previousExhaustion + \" Sat: \" + previousSaturation + \" Ticks since: \" +\n                        (lastTick == null ? \"?\" : (currentTick - lastTick)),\n                p);\n\n        // If we're skipping this heal, we must fix the exhaustion in the following tick\n        if (lastTick != null && currentTick - lastTick < intervalTicks) {\n            Bukkit.getScheduler().runTaskLater(plugin, () -> p.setExhaustion(previousExhaustion), 1L);\n            return;\n        }\n\n        final double maxHealth = p.getAttribute(XAttribute.MAX_HEALTH.get()).getValue();\n        final double playerHealth = p.getHealth();\n\n        if (playerHealth < maxHealth) {\n            p.setHealth(MathsHelper.clamp(playerHealth + healAmount, 0.0, maxHealth));\n            lastHealTick.put(playerId, currentTick);\n        }\n\n        // Calculate new exhaustion value, must be between 0 and 4. If above, it will\n        // reduce the saturation in the following tick.\n        Bukkit.getScheduler().runTaskLater(plugin, () -> {\n            // We do this in the next tick because bukkit doesn't stop the exhaustion change\n            // when cancelling the event\n            p.setExhaustion(previousExhaustion + exhaustionToApply);\n            debug(\"Exh before: \" + previousExhaustion + \" Now: \" + p.getExhaustion() +\n                    \" Sat now: \" + previousSaturation, p);\n        }, 1L);\n    }\n\n    @EventHandler\n    public void onPlayerQuit(PlayerQuitEvent e) {\n        lastHealTick.remove(e.getPlayer().getUniqueId());\n        stopTickTaskIfIdle();\n    }\n\n    private void ensureTickTaskRunning() {\n        if (tickTask != null) return;\n        tickCounter = 0;\n        tickTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {\n            tickCounter++;\n            if (lastHealTick.isEmpty()) {\n                stopTickTaskIfIdle();\n            }\n        }, 1L, 1L);\n    }\n\n    private void stopTickTaskIfIdle() {\n        if (tickTask == null) return;\n        if (!lastHealTick.isEmpty()) return;\n        tickTask.cancel();\n        tickTask = null;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleProjectileKnockback.java",
    "content": "/*\r\n * This Source Code Form is subject to the terms of the Mozilla Public\r\n * License, v. 2.0. If a copy of the MPL was not distributed with this\r\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\r\n */\r\npackage kernitus.plugin.OldCombatMechanics.module;\r\n\r\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\r\nimport org.bukkit.entity.EntityType;\r\nimport org.bukkit.event.EventHandler;\r\nimport org.bukkit.event.EventPriority;\r\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\r\nimport org.bukkit.event.entity.EntityDamageEvent;\r\n\r\nimport java.util.Locale;\r\n\r\n/**\r\n * Adds knockback to eggs, snowballs and ender pearls.\r\n */\r\npublic class ModuleProjectileKnockback extends OCMModule {\r\n\r\n    public ModuleProjectileKnockback(OCMMain plugin) {\r\n        super(plugin, \"projectile-knockback\");\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.NORMAL)\r\n    public void onEntityHit(EntityDamageByEntityEvent e) {\r\n        if (!isEnabled(e.getDamager(), e.getEntity())) return;\r\n\r\n        final EntityType type = e.getDamager().getType();\r\n\r\n        switch (type) {\r\n            case SNOWBALL: case EGG: case ENDER_PEARL:\r\n                if (e.getDamage() == 0.0) { // So we don't override enderpearl fall damage\r\n                    e.setDamage(module().getDouble(\"damage.\" + type.toString().toLowerCase(Locale.ROOT)));\r\n                    if (e.isApplicable(EntityDamageEvent.DamageModifier.ABSORPTION))\r\n                        e.setDamage(EntityDamageEvent.DamageModifier.ABSORPTION, 0);\r\n                }\r\n        }\r\n\r\n    }\r\n}"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleShieldDamageReduction.java",
    "content": "/*\r\n * This Source Code Form is subject to the terms of the Mozilla Public\r\n * License, v. 2.0. If a copy of the MPL was not distributed with this\r\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\r\n */\r\npackage kernitus.plugin.OldCombatMechanics.module;\r\n\r\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking;\nimport org.bukkit.Bukkit;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\r\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\r\nimport org.bukkit.event.entity.EntityDamageEvent.DamageCause;\nimport org.bukkit.event.entity.EntityDamageEvent.DamageModifier;\nimport org.bukkit.event.player.PlayerItemDamageEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\r\n/**\r\n * Allows customising the shield damage reduction percentages.\r\n */\r\npublic class ModuleShieldDamageReduction extends OCMModule {\n\n    private int genericDamageReductionAmount, genericDamageReductionPercentage, projectileDamageReductionAmount, projectileDamageReductionPercentage;\n\n    // When a hit is fully blocked (0 final damage), vanilla can still damage armour durability.\n    // We cancel that armour durability for one tick only (just long enough for PlayerItemDamageEvent to fire).\n    //\n    // Performance/correctness:\n    // - Use a normal HashMap (WeakHashMap<UUID, ...> can drop entries unpredictably).\n    // - Avoid scheduling one task per fully-blocked hit: keep a shared cleanup task that runs only while entries exist.\n    private final Map<UUID, FullyBlockedArmour> fullyBlocked = new HashMap<>();\n    private BukkitTask fullyBlockedCleanupTask;\n    private long fullyBlockedTickCounter;\n\n    public ModuleShieldDamageReduction(OCMMain plugin) {\n        super(plugin, \"shield-damage-reduction\");\n        reload();\n    }\n\r\n    @Override\r\n    public void reload() {\r\n        genericDamageReductionAmount = module().getInt(\"generalDamageReductionAmount\", 1);\r\n        genericDamageReductionPercentage = module().getInt(\"generalDamageReductionPercentage\", 50);\r\n        projectileDamageReductionAmount = module().getInt(\"projectileDamageReductionAmount\", 1);\r\n        projectileDamageReductionPercentage = module().getInt(\"projectileDamageReductionPercentage\", 50);\r\n    }\r\n\r\n    @EventHandler(priority = EventPriority.LOWEST)\r\n    public void onItemDamage(PlayerItemDamageEvent e) {\n        final Player player = e.getPlayer();\n        if (!isEnabled(player)) return;\n        final UUID uuid = player.getUniqueId();\n        final ItemStack item = e.getItem();\n\n        if (fullyBlocked.containsKey(uuid)) {\n            final FullyBlockedArmour data = fullyBlocked.get(uuid);\n            if (data == null) return;\n            final List<ItemStack> armour = data.armour;\n            // ItemStack.equals() checks material, durability and quantity to make sure nothing changed in the meantime\n            // We're checking all the pieces this way just in case they're wearing two helmets or something strange\n            final List<ItemStack> matchedPieces = armour.stream().filter(piece -> piece.equals(item)).collect(Collectors.toList());\n            armour.removeAll(matchedPieces);\n            debug(\"Ignoring armour durability damage due to full block\", player);\n            if (!matchedPieces.isEmpty()) {\n                e.setCancelled(true);\n            }\n        }\n    }\n\r\n    @EventHandler(priority = EventPriority.LOWEST)\r\n    public void onHit(EntityDamageByEntityEvent e) {\n        final Entity entity = e.getEntity();\n\n        if (!(entity instanceof Player)) return;\n\n        final Player player = (Player) entity;\n\n        if (!isEnabled(e.getDamager(), player)) return;\n\n        // Paper sword blocking sets the BLOCKING modifier to emulate 1.8 sword blocking. This module is for\n        // shield blocking only; do not double-apply a second reduction when the player is blocking with a sword.\n        final ModuleSwordBlocking swordBlocking = ModuleSwordBlocking.getInstance();\n        if (swordBlocking != null && swordBlocking.isPaperSwordBlocking(player)) return;\n\n        // Blocking is calculated after base and hard hat, and before armour etc.\n        final double baseDamage = e.getDamage(DamageModifier.BASE) + e.getDamage(DamageModifier.HARD_HAT);\n        if (!shieldBlockedDamage(baseDamage, e.getDamage(DamageModifier.BLOCKING))) return;\n\r\n        final double damageReduction = getDamageReduction(baseDamage, e.getCause());\r\n        e.setDamage(DamageModifier.BLOCKING, -damageReduction);\r\n        final double currentDamage = baseDamage - damageReduction;\r\n\r\n        debug(\"Blocking: \" + baseDamage + \" - \" + damageReduction + \" = \" + currentDamage, player);\r\n        debug(\"Blocking: \" + baseDamage + \" - \" + damageReduction + \" = \" + currentDamage);\r\n\r\n        final UUID uuid = player.getUniqueId();\n\n        if (currentDamage <= 0) { // Make sure armour is not damaged if fully blocked\n            final List<ItemStack> armour = Arrays.stream(player.getInventory().getArmorContents()).filter(Objects::nonNull).collect(Collectors.toList());\n            fullyBlocked.put(uuid, new FullyBlockedArmour(armour, fullyBlockedTickCounter + 1L));\n            ensureFullyBlockedCleanupTaskRunning();\n        }\n    }\n\n    private void ensureFullyBlockedCleanupTaskRunning() {\n        if (fullyBlockedCleanupTask != null) return;\n        fullyBlockedTickCounter = 0;\n\n        fullyBlockedCleanupTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {\n            fullyBlockedTickCounter++;\n            if (fullyBlocked.isEmpty()) {\n                stopFullyBlockedCleanupTaskIfIdle();\n                return;\n            }\n\n            final Iterator<Map.Entry<UUID, FullyBlockedArmour>> it = fullyBlocked.entrySet().iterator();\n            while (it.hasNext()) {\n                final FullyBlockedArmour data = it.next().getValue();\n                if (data == null || data.expiresAtTick <= fullyBlockedTickCounter) {\n                    it.remove();\n                }\n            }\n\n            stopFullyBlockedCleanupTaskIfIdle();\n        }, 1L, 1L);\n    }\n\n    private void stopFullyBlockedCleanupTaskIfIdle() {\n        if (fullyBlockedCleanupTask == null) return;\n        if (!fullyBlocked.isEmpty()) return;\n        fullyBlockedCleanupTask.cancel();\n        fullyBlockedCleanupTask = null;\n    }\n\n    private static final class FullyBlockedArmour {\n        private final List<ItemStack> armour;\n        private final long expiresAtTick;\n\n        private FullyBlockedArmour(List<ItemStack> armour, long expiresAtTick) {\n            this.armour = armour;\n            this.expiresAtTick = expiresAtTick;\n        }\n    }\n\n    private double getDamageReduction(double damage, DamageCause damageCause) {\n        // 1.8 NMS code, where f is damage done, to calculate new damage.\n        // f = (1.0F + f) * 0.5F;\n\r\n        // We subtract, to calculate damage reduction instead of new damage\r\n        double reduction = damage - (damageCause == DamageCause.PROJECTILE ? projectileDamageReductionAmount : genericDamageReductionAmount);\r\n\r\n        // Reduce to percentage\r\n        reduction *= (damageCause == DamageCause.PROJECTILE ? projectileDamageReductionPercentage : genericDamageReductionPercentage) / 100.0;\r\n\r\n        // Don't reduce by more than the actual damage done\r\n        // As far as I can tell this is not checked in 1.8NMS, and if the damage was low enough\r\n        // blocking would lead to higher damage. However, this is hardly the desired result.\r\n        if (reduction < 0) reduction = 0;\r\n\r\n        return reduction;\r\n    }\r\n\r\n    private boolean shieldBlockedDamage(double attackDamage, double blockingReduction) {\r\n        // Only reduce damage if they were hit head on, i.e. the shield blocked some of the damage\r\n        // This also takes into account damages that are not blocked by shields\r\n        return attackDamage > 0 && blockingReduction < 0;\r\n    }\r\n}\r\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleSwordBlocking.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Location;\nimport org.bukkit.Material;\nimport org.bukkit.block.Block;\nimport org.bukkit.entity.Item;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.Listener;\nimport org.bukkit.event.block.Action;\nimport org.bukkit.event.block.BlockCanBuildEvent;\nimport org.bukkit.event.entity.PlayerDeathEvent;\nimport org.bukkit.event.inventory.ClickType;\nimport org.bukkit.event.inventory.InventoryClickEvent;\nimport org.bukkit.event.inventory.InventoryDragEvent;\nimport org.bukkit.event.inventory.InventoryType;\nimport org.bukkit.event.player.*;\nimport org.bukkit.inventory.EquipmentSlot;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.inventory.PlayerInventory;\nimport org.bukkit.inventory.meta.ItemMeta;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.lang.reflect.Method;\nimport java.util.*;\n\npublic class ModuleSwordBlocking extends OCMModule {\n\n    // Not using WeakHashMaps here, for extra reliability\n    private final Map<UUID, ItemStack> storedItems = new HashMap<>();\n    private final Map<UUID, LegacySwordBlockState> legacyStates = new HashMap<>();\n    private BukkitTask legacyTask;\n    private long tickCounter;\n    private int restoreDelay;\n    private boolean paperSupported;\n    private Object paperAdapter;\n    private java.lang.reflect.Method paperApply;\n    private java.lang.reflect.Method paperClear;\n    private java.lang.reflect.Method paperHasConsumable;\n    private java.lang.reflect.Method paperIsBlockingSword;\n    private Method startUsingItemMethod;\n    private boolean startUsingItemMethodResolved;\n    private Method craftPlayerGetHandleMethod;\n    private final Map<Class<?>, Method> nmsStartUsingItemCache = new HashMap<>();\n    private Object minClientVersion;\n    private Method packetEventsGetAPI;\n    private Method packetEventsGetPlayerManager;\n    private Method packetEventsGetUser;\n    private Method packetEventsGetClientVersion;\n    private Method packetEventsUserGetClientVersion;\n    private Method packetEventsIsOlderThan;\n    private Object legacyShieldMarkerKey;\n    private Object legacyShieldMarkerByteType;\n    private Method itemMetaGetPersistentDataContainer;\n    private Method persistentDataContainerSet;\n    private Method persistentDataContainerHas;\n    private static final long ENTITY_INTERACTION_DEDUPE_WINDOW_NANOS = 75_000_000L;\n    private static final long ENTITY_INTERACTION_PRUNE_INTERVAL_NANOS = 250_000_000L;\n    private static final int ENTITY_INTERACTION_FORCE_PRUNE_SIZE = 128;\n    private final Map<EntityInteractionKey, Long> handledEntityInteractions = new HashMap<>();\n    private long nextEntityInteractionPruneAtNanos;\n    private static ModuleSwordBlocking INSTANCE;\n\n    // Only used <1.13, where BlockCanBuildEvent.getPlayer() is not available\n    private Map<Location, UUID> lastInteractedBlocks;\n\n    public ModuleSwordBlocking(OCMMain plugin) {\n        super(plugin, \"sword-blocking\");\n        INSTANCE = this;\n\n        if (!Reflector.versionIsNewerOrEqualTo(1, 13, 0)) {\n            lastInteractedBlocks = new WeakHashMap<>();\n        }\n\n        initialisePaperAdapter();\n        initialisePacketEventsClientVersion();\n        initialiseLegacyShieldMarker();\n        Bukkit.getPluginManager().registerEvents(new ConsumableLifecycleHandler(), plugin);\n    }\n\n    @Override\n    public void reload() {\n        restoreDelay = module().getInt(\"restoreDelay\", 40);\n        handledEntityInteractions.clear();\n        nextEntityInteractionPruneAtNanos = 0L;\n        if (!paperSupported || paperAdapter == null) return;\n        if (isEnabled() && isPaperAnimationEnabled()) return;\n\n        final Runnable cleanup = () -> {\n            for (Player player : Bukkit.getOnlinePlayers()) {\n                onModesetChange(player);\n            }\n        };\n\n        if (Bukkit.isPrimaryThread()) {\n            cleanup.run();\n        } else {\n            Bukkit.getScheduler().runTask(plugin, cleanup);\n        }\n    }\n\n    @Override\n    public void onModesetChange(Player player) {\n        if (player == null) return;\n\n        if (!isEnabled(player)) {\n            restore(player, true);\n        }\n\n        // Paper component path: when sword-blocking becomes disabled for a player, strip the consumable component\n        // from their items so swords do not remain tainted after mode/world changes.\n        sweepConsumableState(player, true);\n    }\n\n    private void initialisePaperAdapter() {\n        try {\n            // Paper-only optimisation: use Paper's item data components to give swords a BLOCK use animation\n            // (and the BLOCKING component where available). We keep this behind reflection so the plugin can\n            // still compile against Spigot, and we cache the reflective handles once during initialisation to\n            // keep the hot path allocation-free.\n            final Class<?> adapterClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.paper.PaperSwordBlocking\");\n            paperAdapter = adapterClass.getConstructor().newInstance();\n            paperApply = adapterClass.getMethod(\"applyComponents\", ItemStack.class);\n            paperClear = adapterClass.getMethod(\"clearComponents\", ItemStack.class);\n            paperHasConsumable = adapterClass.getMethod(\"hasConsumableComponent\", ItemStack.class);\n            paperIsBlockingSword = adapterClass.getMethod(\"isBlockingSword\", Player.class);\n            paperSupported = true;\n            if (isPaperDataComponentApiPresent()) {\n                plugin.getLogger().info(\"Paper sword blocking components enabled (no offhand shield swap).\");\n            }\n        } catch (Throwable t) {\n            paperSupported = false;\n            paperAdapter = null;\n            paperApply = null;\n            paperClear = null;\n            paperHasConsumable = null;\n            paperIsBlockingSword = null;\n            // Feature-gated warning: only warn when the Paper data component API is present, otherwise this is a\n            // normal Spigot/non-Paper environment where the Paper path is not expected to work.\n            if (isPaperDataComponentApiPresent()) {\n                final Throwable root = (t instanceof java.lang.reflect.InvocationTargetException && ((java.lang.reflect.InvocationTargetException) t).getTargetException() != null)\n                        ? ((java.lang.reflect.InvocationTargetException) t).getTargetException()\n                        : t;\n                plugin.getLogger().warning(\"Paper sword blocking components unavailable; falling back to legacy offhand shield swap. (\" +\n                        root.getClass().getSimpleName() + (root.getMessage() == null ? \"\" : (\": \" + root.getMessage())) + \")\");\n            }\n        }\n    }\n\n    private void initialisePacketEventsClientVersion() {\n        try {\n            final ClassLoader loader = plugin.getClass().getClassLoader();\n            final Class<?> packetEventsClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.PacketEvents\", true, loader);\n            final Class<?> packetEventsApiClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.PacketEventsAPI\", true, loader);\n            final Class<?> playerManagerClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.manager.player.PlayerManager\", true, loader);\n            final Class<?> clientVersionClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.ClientVersion\", true, loader);\n            final Class<?> userClass = Class.forName(\"kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.User\", true, loader);\n            packetEventsGetAPI = packetEventsClass.getMethod(\"getAPI\");\n            packetEventsGetPlayerManager = packetEventsApiClass.getMethod(\"getPlayerManager\");\n            packetEventsGetUser = playerManagerClass.getMethod(\"getUser\", Object.class);\n            packetEventsGetClientVersion = playerManagerClass.getMethod(\"getClientVersion\", Object.class);\n            packetEventsUserGetClientVersion = userClass.getMethod(\"getClientVersion\");\n            packetEventsIsOlderThan = clientVersionClass.getMethod(\"isOlderThan\", clientVersionClass);\n            minClientVersion = Enum.valueOf((Class<? extends Enum>) clientVersionClass, \"V_1_20_5\");\n        } catch (Throwable ignored) {\n            minClientVersion = null;\n            packetEventsGetAPI = null;\n            packetEventsGetPlayerManager = null;\n            packetEventsGetUser = null;\n            packetEventsGetClientVersion = null;\n            packetEventsUserGetClientVersion = null;\n            packetEventsIsOlderThan = null;\n        }\n    }\n\n    private void initialiseLegacyShieldMarker() {\n        try {\n            final Class<?> namespacedKeyClass = Class.forName(\"org.bukkit.NamespacedKey\");\n            final Class<?> pluginClass = Class.forName(\"org.bukkit.plugin.Plugin\");\n            final Class<?> itemMetaClass = Class.forName(\"org.bukkit.inventory.meta.ItemMeta\");\n            final Class<?> persistentDataContainerClass = Class.forName(\"org.bukkit.persistence.PersistentDataContainer\");\n            final Class<?> persistentDataTypeClass = Class.forName(\"org.bukkit.persistence.PersistentDataType\");\n\n            legacyShieldMarkerKey = namespacedKeyClass\n                    .getConstructor(pluginClass, String.class)\n                    .newInstance(plugin, \"temporary_legacy_shield\");\n            legacyShieldMarkerByteType = persistentDataTypeClass.getField(\"BYTE\").get(null);\n            itemMetaGetPersistentDataContainer = itemMetaClass.getMethod(\"getPersistentDataContainer\");\n            persistentDataContainerSet = persistentDataContainerClass.getMethod(\n                    \"set\",\n                    namespacedKeyClass,\n                    persistentDataTypeClass,\n                    Object.class\n            );\n            persistentDataContainerHas = persistentDataContainerClass.getMethod(\n                    \"has\",\n                    namespacedKeyClass,\n                    persistentDataTypeClass\n            );\n        } catch (Throwable ignored) {\n            legacyShieldMarkerKey = null;\n            legacyShieldMarkerByteType = null;\n            itemMetaGetPersistentDataContainer = null;\n            persistentDataContainerSet = null;\n            persistentDataContainerHas = null;\n        }\n    }\n\n    private boolean supportsPaperAnimation(Player player) {\n        if (!paperSupported || paperAdapter == null) return false;\n        if (player == null) return false;\n        if (!isPaperAnimationEnabled()) return false;\n        if (minClientVersion == null) return false;\n        try {\n            if (packetEventsGetAPI == null || packetEventsGetPlayerManager == null || packetEventsGetClientVersion == null || packetEventsIsOlderThan == null) {\n                return false;\n            }\n            final Object api = packetEventsGetAPI.invoke(null);\n            if (api == null) return false;\n            final Object playerManager = packetEventsGetPlayerManager.invoke(api);\n            if (playerManager == null) return false;\n            Object clientVersion = packetEventsGetClientVersion.invoke(playerManager, player);\n            if (clientVersion == null && packetEventsGetUser != null && packetEventsUserGetClientVersion != null) {\n                final Object user = packetEventsGetUser.invoke(playerManager, player);\n                if (user != null) {\n                    clientVersion = packetEventsUserGetClientVersion.invoke(user);\n                }\n            }\n            if (clientVersion == null) return true;\n            if (isUnknownClientVersion(clientVersion)) {\n                // During very early login or synthetic test players, PacketEvents may not have a User yet.\n                // Keep animation support in that case to avoid regressing normal modern-client behaviour.\n                if (packetEventsGetUser != null) {\n                    final Object user = packetEventsGetUser.invoke(playerManager, player);\n                    if (user == null) return true;\n                }\n                return false;\n            }\n            final Object older = packetEventsIsOlderThan.invoke(clientVersion, minClientVersion);\n            return !(older instanceof Boolean && (Boolean) older);\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private boolean isPaperDataComponentApiPresent() {\n        try {\n            Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\");\n            return true;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onBlockPlace(BlockCanBuildEvent e) {\n        if (e.isBuildable()) return;\n\n        Player player;\n\n        // If <1.13 get player who last interacted with block\n        if (lastInteractedBlocks != null) {\n            final Location blockLocation = e.getBlock().getLocation();\n            final UUID uuid = lastInteractedBlocks.remove(blockLocation);\n            player = Bukkit.getServer().getPlayer(uuid);\n        } else player = e.getPlayer();\n\n        if (player == null || !isEnabled(player)) return;\n\n        doShieldBlock(player);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onRightClick(PlayerInteractEvent e) {\n        final Action action = e.getAction();\n        final Player player = e.getPlayer();\n\n        if (!isEnabled(player)) return;\n\n        if (action != Action.RIGHT_CLICK_BLOCK && action != Action.RIGHT_CLICK_AIR) return;\n        // If they clicked on an interactive block, the 2nd event with the offhand won't fire\n        // This is also the case if the main hand item was used, e.g. a bow\n        if (action == Action.RIGHT_CLICK_BLOCK && e.getHand() == EquipmentSlot.HAND) return;\n        if (e.isBlockInHand()) {\n            if (lastInteractedBlocks != null) {\n                final Block clickedBlock = e.getClickedBlock();\n                if (clickedBlock != null)\n                    lastInteractedBlocks.put(clickedBlock.getLocation(), player.getUniqueId());\n            }\n            return; // Handle failed block place in separate listener\n        }\n\n        doShieldBlock(player);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onRightClickEntity(PlayerInteractEntityEvent event) {\n        handleEntityRightClick(event.getPlayer(), event.getRightClicked(), event.getHand());\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onRightClickEntityAt(PlayerInteractAtEntityEvent event) {\n        handleEntityRightClick(event.getPlayer(), event.getRightClicked(), event.getHand());\n    }\n\n    private void handleEntityRightClick(Player player, org.bukkit.entity.Entity clickedEntity, EquipmentSlot hand) {\n        if (player == null || clickedEntity == null) return;\n        if (!isEnabled(player)) return;\n        if (hand != EquipmentSlot.HAND) return;\n        if (!markEntityInteractionHandled(player, clickedEntity, hand)) return;\n        doShieldBlock(player);\n    }\n\n    private boolean markEntityInteractionHandled(Player player, org.bukkit.entity.Entity clickedEntity, EquipmentSlot hand) {\n        final long now = System.nanoTime();\n        lazyPruneHandledEntityInteractions(now, false);\n\n        final EntityInteractionKey key = new EntityInteractionKey(player.getUniqueId(), clickedEntity.getUniqueId(), hand);\n        final Long expiresAt = handledEntityInteractions.get(key);\n        if (expiresAt != null && expiresAt > now) {\n            return false;\n        }\n\n        handledEntityInteractions.put(key, now + ENTITY_INTERACTION_DEDUPE_WINDOW_NANOS);\n        lazyPruneHandledEntityInteractions(now, handledEntityInteractions.size() >= ENTITY_INTERACTION_FORCE_PRUNE_SIZE);\n        return true;\n    }\n\n    private void lazyPruneHandledEntityInteractions(long nowNanos, boolean force) {\n        if (!force && nowNanos < nextEntityInteractionPruneAtNanos) return;\n\n        final Iterator<Map.Entry<EntityInteractionKey, Long>> it = handledEntityInteractions.entrySet().iterator();\n        while (it.hasNext()) {\n            if (it.next().getValue() <= nowNanos) {\n                it.remove();\n            }\n        }\n        nextEntityInteractionPruneAtNanos = nowNanos + ENTITY_INTERACTION_PRUNE_INTERVAL_NANOS;\n    }\n\n    private void doShieldBlock(Player player) {\n        final PlayerInventory inventory = player.getInventory();\n\n        final ItemStack mainHandItem = inventory.getItemInMainHand();\n        final ItemStack offHandItem = inventory.getItemInOffHand();\n\n        if (!isHoldingSword(mainHandItem.getType())) return;\n\n        if (module().getBoolean(\"use-permission\") &&\n                !player.hasPermission(\"oldcombatmechanics.swordblock\")) return;\n\n        if (supportsPaperAnimation(player)) {\n            // Modern Paper path: we can provide a sword blocking animation via components, without swapping an\n            // offhand shield. This avoids the legacy polling/restore tasks and avoids interfering with offhand\n            // gameplay items (totems, food, etc.).\n            // Set first, then re-read and patch the inventory-backed stack (CraftItemStack) so NMS components\n            // are applied to the real server-side item.\n            inventory.setItemInMainHand(mainHandItem);\n            final ItemStack invMain = inventory.getItemInMainHand();\n            if (applyConsumableComponent(player, invMain)) {\n                inventory.setItemInMainHand(invMain);\n            }\n            startUsingMainHandIfSupported(player);\n            return;\n        }\n\n        if (stripConsumable(mainHandItem)) {\n            inventory.setItemInMainHand(mainHandItem);\n        }\n\n        final UUID id = player.getUniqueId();\n\n        if (!isPlayerBlocking(player)) {\n            if (hasShield(inventory)) return;\n            debug(\"Storing \" + offHandItem, player);\n            storedItems.put(id, offHandItem);\n\n            inventory.setItemInOffHand(createTemporaryLegacyShield());\n            // Force an inventory update to avoid ghost items\n            player.updateInventory();\n            // Best-effort: ask the server to start using the offhand item so blocking becomes visible immediately\n            // (and works for fake players / synthetic events). If the API is not present, we fall back silently.\n            startUsingItemIfSupported(player, EquipmentSlot.OFF_HAND);\n            startUsingItemNmsIfSupported(player, true);\n        }\n        // Legacy path: per-player state is required because we temporarily equip a shield. We restore the\n        // original offhand item once the player stops blocking or after a configurable delay.\n        scheduleLegacyRestore(player);\n\n        applyConsumableComponent(player, mainHandItem);\n    }\n\n    @EventHandler\n    public void onHotBarChange(PlayerItemHeldEvent e) {\n        restore(e.getPlayer(), true);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onWorldChange(PlayerChangedWorldEvent e) {\n        restore(e.getPlayer(), true);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPlayerJoin(PlayerJoinEvent e) {\n        restore(e.getPlayer(), true);\n        stripConsumableState(e.getPlayer(), true);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPlayerLogout(PlayerQuitEvent e) {\n        restore(e.getPlayer(), true);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPlayerDeath(PlayerDeathEvent e) {\n        final Player p = e.getEntity();\n        final UUID id = p.getUniqueId();\n        final boolean hadMarkedTemporaryOffhand = hasTemporaryLegacyShieldMarker(p.getInventory().getItemInOffHand());\n        final ItemStack storedOffhand = storedItems.remove(id);\n        final LegacySwordBlockState removedLegacyState = legacyStates.remove(id);\n        if (storedOffhand == null && removedLegacyState == null) return;\n\n        stopLegacyTaskIfIdle();\n\n        if (storedOffhand == null) {\n            return;\n        }\n\n        if (e.getKeepInventory()) {\n            p.getInventory().setItemInOffHand(storedOffhand);\n            return;\n        }\n\n        final List<ItemStack> drops = e.getDrops();\n        int temporaryShieldDropIndex = -1;\n        for (int i = 0; i < drops.size(); i++) {\n            if (isTemporaryLegacyShieldDrop(drops.get(i))) {\n                temporaryShieldDropIndex = i;\n                break;\n            }\n        }\n\n        // Synthetic/manual death events may construct shield drops without preserving item metadata.\n        // If we know the player had our marked temporary shield in offhand, allow a single shield rewrite.\n        if (temporaryShieldDropIndex < 0 && hadMarkedTemporaryOffhand && canMarkTemporaryLegacyShield()) {\n            temporaryShieldDropIndex = firstShieldDropIndex(drops);\n        }\n\n        if (temporaryShieldDropIndex >= 0) {\n            if (storedOffhand.getType() == Material.AIR) {\n                drops.remove(temporaryShieldDropIndex);\n            } else {\n                drops.set(temporaryShieldDropIndex, storedOffhand);\n            }\n            return;\n        }\n\n        if (storedOffhand.getType() != Material.AIR) {\n            drops.add(storedOffhand);\n        }\n    }\n\n    private int firstShieldDropIndex(List<ItemStack> drops) {\n        for (int i = 0; i < drops.size(); i++) {\n            final ItemStack item = drops.get(i);\n            if (item != null && item.getType() == Material.SHIELD) {\n                return i;\n            }\n        }\n        return -1;\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onPlayerSwapHandItems(PlayerSwapHandItemsEvent e) {\n        final UUID uuid = e.getPlayer().getUniqueId();\n        if (!areItemsStored(uuid)) return;\n\n        if (supportsPaperAnimation(e.getPlayer())) {\n            restore(e.getPlayer(), true);\n            return;\n        }\n\n        e.setCancelled(true);\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onInventoryClick(InventoryClickEvent e) {\n        if (!(e.getWhoClicked() instanceof Player)) {\n            return;\n        }\n        final Player player = (Player) e.getWhoClicked();\n        if (!areItemsStored(player.getUniqueId())) {\n            return;\n        }\n\n        if (isSwapOffhandClick(e) || isTemporaryOffhandShieldClick(e)) {\n            e.setCancelled(true);\n            restore(player, true);\n        }\n    }\n\n    @EventHandler(priority = EventPriority.HIGHEST)\n    public void onItemDrop(PlayerDropItemEvent e) {\n        final Item is = e.getItemDrop();\n        final Player p = e.getPlayer();\n\n        if (areItemsStored(p.getUniqueId()) && is.getItemStack().getType() == Material.SHIELD) {\n            // Do not cancel here: this event can represent unrelated shield drops from inventory/hotbar.\n            // We only need to end legacy temporary-shield state immediately.\n            restore(p, true);\n        }\n    }\n\n    private boolean isTemporaryOffhandShieldClick(InventoryClickEvent event) {\n        if (event.getClickedInventory() == null) return false;\n        if (event.getClickedInventory().getType() != InventoryType.PLAYER) return false;\n        if (event.getSlot() != 40) return false;\n\n        final ItemStack current = event.getCurrentItem();\n        return current != null && current.getType() == Material.SHIELD;\n    }\n\n    private boolean isSwapOffhandClick(InventoryClickEvent event) {\n        try {\n            return event.getClick() == ClickType.SWAP_OFFHAND;\n        } catch (NoSuchFieldError ignored) {\n            return false;\n        }\n    }\n\n    private ItemStack createTemporaryLegacyShield() {\n        final ItemStack shield = new ItemStack(Material.SHIELD);\n        markTemporaryLegacyShield(shield);\n        return shield;\n    }\n\n    private void markTemporaryLegacyShield(ItemStack item) {\n        if (!canMarkTemporaryLegacyShield()) return;\n        if (item == null || item.getType() != Material.SHIELD) return;\n        try {\n            final ItemMeta meta = item.getItemMeta();\n            if (meta == null) return;\n            final Object persistentDataContainer = itemMetaGetPersistentDataContainer.invoke(meta);\n            if (persistentDataContainer == null) return;\n            persistentDataContainerSet.invoke(\n                    persistentDataContainer,\n                    legacyShieldMarkerKey,\n                    legacyShieldMarkerByteType,\n                    Byte.valueOf((byte) 1)\n            );\n            item.setItemMeta(meta);\n        } catch (Throwable ignored) {\n        }\n    }\n\n    private boolean canMarkTemporaryLegacyShield() {\n        return legacyShieldMarkerKey != null\n                && legacyShieldMarkerByteType != null\n                && itemMetaGetPersistentDataContainer != null\n                && persistentDataContainerSet != null\n                && persistentDataContainerHas != null;\n    }\n\n    private boolean isTemporaryLegacyShieldDrop(ItemStack item) {\n        if (item == null || item.getType() != Material.SHIELD) return false;\n        if (!canMarkTemporaryLegacyShield()) {\n            // Safety-first fallback: without marker support we cannot reliably distinguish temporary shields from\n            // legitimate plain shields, so avoid rewriting any shield drops.\n            return false;\n        }\n        return hasTemporaryLegacyShieldMarker(item);\n    }\n\n    private boolean hasTemporaryLegacyShieldMarker(ItemStack item) {\n        if (!canMarkTemporaryLegacyShield()) return false;\n        try {\n            final ItemMeta meta = item.getItemMeta();\n            if (meta == null) return false;\n            final Object persistentDataContainer = itemMetaGetPersistentDataContainer.invoke(meta);\n            if (persistentDataContainer == null) return false;\n            final Object marked = persistentDataContainerHas.invoke(\n                    persistentDataContainer,\n                    legacyShieldMarkerKey,\n                    legacyShieldMarkerByteType\n            );\n            return marked instanceof Boolean && (Boolean) marked;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private void restore(Player player) {\n        restore(player, false);\n    }\n\n    private void restore(Player p, boolean force) {\n        restore(p, force, false);\n    }\n\n    private void restore(Player p, boolean force, boolean fromLegacyTask) {\n        final UUID id = p.getUniqueId();\n\n        if (!areItemsStored(id)) return;\n\n        // This method only runs when a legacy offhand item has been stored, so restore unconditionally.\n        // Do not gate this by generic Paper support: older clients on Paper still use the legacy shield fallback.\n\n        // If they are still blocking with the shield, postpone restoring\n        if (!force && isPlayerBlocking(p)) {\n            if (!fromLegacyTask) {\n                scheduleLegacyRestore(p);\n            } else {\n                // When running inside the legacy tick task, do not touch the map structure while iterating.\n                // Just extend the restore deadline.\n                final LegacySwordBlockState state = legacyStates.get(id);\n                if (state != null) {\n                    state.restoreAtTick = tickCounter + Math.max(0, restoreDelay);\n                }\n            }\n            return;\n        }\n\n        p.getInventory().setItemInOffHand(storedItems.remove(id));\n        if (!fromLegacyTask) {\n            legacyStates.remove(id);\n            stopLegacyTaskIfIdle();\n        }\n    }\n\n    private void scheduleLegacyRestore(Player p) {\n        final UUID id = p.getUniqueId();\n        final LegacySwordBlockState state = legacyStates.computeIfAbsent(id, ignored -> new LegacySwordBlockState());\n        state.restoreAtTick = tickCounter + Math.max(0, restoreDelay);\n        state.nextBlockingCheckTick = tickCounter + 10L;\n        ensureLegacyTaskRunning();\n    }\n\n    private boolean areItemsStored(UUID uuid) {\n        return storedItems.containsKey(uuid);\n    }\n\n    /**\n     * Checks whether player is blocking or they have just begun to and shield is not fully up yet.\n     */\n    private boolean isPlayerBlocking(Player player) {\n        if (!hasShield(player.getInventory())) return false;\n\n        return player.isBlocking() ||\n                (Reflector.versionIsNewerOrEqualTo(1, 11, 0) && player.isHandRaised());\n    }\n\n    private boolean hasShield(PlayerInventory inventory) {\n        return inventory.getItemInOffHand().getType() == Material.SHIELD;\n    }\n\n    private boolean isHoldingSword(Material mat) {\n        return mat.toString().endsWith(\"_SWORD\");\n    }\n\n    public static ModuleSwordBlocking getInstance() {\n        return INSTANCE;\n    }\n\n    /**\n     * Paper path: compute 1.8-style blocking reduction for sword blocking without offhand shield.\n     *\n     * @param event          underlying damage event\n     * @param incomingDamage current damage value before blocking is applied (attack side only)\n     * @return reduction amount to subtract from damage, or 0 if not blocking/unsupported.\n     */\n    public double applyPaperBlockingReduction(org.bukkit.event.entity.EntityDamageByEntityEvent event, double incomingDamage) {\n        if (!paperSupported || paperAdapter == null) return 0;\n        if (!(event.getEntity() instanceof Player)) return 0;\n        final Player player = (Player) event.getEntity();\n        if (!isPaperAnimationEnabled()) return 0;\n        if (!isEnabled(event.getDamager(), player)) return 0;\n        if (!isHoldingSword(player.getInventory().getItemInMainHand().getType())) return 0;\n        if (!isPaperSwordBlocking(player)) return 0;\n\n        final int amount = plugin.getConfig().getInt(\"shield-damage-reduction.generalDamageReductionAmount\", 1);\n        final int percent = plugin.getConfig().getInt(\"shield-damage-reduction.generalDamageReductionPercentage\", 50);\n        double reduction = (incomingDamage - amount) * (percent / 100.0);\n        if (reduction < 0) reduction = 0;\n        if (reduction > incomingDamage) reduction = incomingDamage;\n        return reduction;\n    }\n\n    public boolean isPaperSwordBlocking(Player player) {\n        if (!paperSupported || paperAdapter == null) return false;\n        if (player == null) return false;\n        if (!isPaperAnimationEnabled()) return false;\n        try {\n            if (paperIsBlockingSword != null) {\n                final Object result = paperIsBlockingSword.invoke(paperAdapter, player);\n                if (result instanceof Boolean) {\n                    return (Boolean) result;\n                }\n            }\n        } catch (Throwable ignored) {\n        }\n        // Fallback: may work on some combinations, but is not reliable for consumable-based sword blocking.\n        return player.isBlocking() || (Reflector.versionIsNewerOrEqualTo(1, 11, 0) && player.isHandRaised());\n    }\n\n    /* ---------- Paper consumable component (animation-only) ---------- */\n\n    private boolean applyConsumableComponent(Player player, ItemStack item) {\n        if (!supportsPaperAnimation(player) || paperApply == null) return false;\n        if (item == null || item.getType() == Material.AIR || !isHoldingSword(item.getType())) return false;\n        if (!isEnabled(player)) return false;\n        if (hasConsumableComponent(item)) return false;\n        try {\n            paperApply.invoke(paperAdapter, item);\n            return true;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private void startUsingMainHandIfSupported(Player player) {\n        startUsingItemIfSupported(player, EquipmentSlot.HAND);\n    }\n\n    private void startUsingItemIfSupported(Player player, EquipmentSlot slot) {\n        // Feature-gated: Paper exposes LivingEntity#startUsingItem(EquipmentSlot). Spigot does not.\n        // Without this, some server/client combinations do not transition into the \"hand raised\" state, even if the\n        // item has a BLOCK use animation (or a shield is injected on legacy path).\n        if (player == null || slot == null) return;\n\n        if (!startUsingItemMethodResolved) {\n            startUsingItemMethodResolved = true;\n            try {\n                final Class<?> livingEntityClass = Class.forName(\"org.bukkit.entity.LivingEntity\");\n                startUsingItemMethod = livingEntityClass.getMethod(\"startUsingItem\", EquipmentSlot.class);\n            } catch (Throwable ignored) {\n                startUsingItemMethod = null;\n            }\n        }\n\n        final java.lang.reflect.Method m = startUsingItemMethod;\n        if (m == null) return;\n        try {\n            m.invoke(player, slot);\n        } catch (Throwable ignored) {\n        }\n    }\n\n    private void startUsingItemNmsIfSupported(Player player, boolean offhand) {\n        // Ultra-legacy fallback for environments without LivingEntity#startUsingItem(EquipmentSlot).\n        // We reflect into NMS to call LivingEntity#startUsingItem(InteractionHand/EnumHand).\n        if (player == null) return;\n        try {\n            final Class<?> craftPlayerClass = Class.forName(\"org.bukkit.craftbukkit.entity.CraftPlayer\");\n            if (!craftPlayerClass.isInstance(player)) return;\n\n            Method getHandle = craftPlayerGetHandleMethod;\n            if (getHandle == null) {\n                getHandle = Reflector.getMethod(craftPlayerClass, \"getHandle\");\n                if (getHandle == null) return;\n                craftPlayerGetHandleMethod = getHandle;\n            }\n            final Object handle = getHandle.invoke(player);\n            if (handle == null) return;\n\n            final Class<?> handleClass = handle.getClass();\n            Method startUsing = nmsStartUsingItemCache.get(handleClass);\n            if (startUsing == null) {\n                startUsing = resolveNmsStartUsingItem(handleClass);\n                nmsStartUsingItemCache.put(handleClass, startUsing);\n            }\n            if (startUsing == null) return;\n\n            final Class<?> handType = startUsing.getParameterTypes()[0];\n            if (!handType.isEnum()) return;\n            final Object hand = enumConstantByName(handType, offhand ? \"OFF_HAND\" : \"MAIN_HAND\");\n            if (hand == null) return;\n\n            startUsing.invoke(handle, hand);\n        } catch (Throwable ignored) {\n        }\n    }\n\n    private java.lang.reflect.Method resolveNmsStartUsingItem(Class<?> handleClass) {\n        // Prefer Mojang-named method where available.\n        final Method direct = Reflector.getMethod(handleClass, \"startUsingItem\", 1);\n        if (direct != null && direct.getReturnType() == void.class && direct.getParameterTypes()[0].isEnum()) {\n            final Class<?> hand = direct.getParameterTypes()[0];\n            if (enumHasConstant(hand, \"MAIN_HAND\") && enumHasConstant(hand, \"OFF_HAND\")) {\n                return direct;\n            }\n        }\n\n        // Heuristic fallback: any void method taking an enum hand with MAIN_HAND/OFF_HAND constants.\n        Method best = null;\n        int bestScore = -1;\n        Class<?> current = handleClass;\n        while (current != null && current != Object.class) {\n            for (Method m : current.getDeclaredMethods()) {\n                if (m.getParameterCount() != 1) continue;\n                if (m.getReturnType() != void.class) continue;\n                final Class<?> param = m.getParameterTypes()[0];\n                if (!param.isEnum()) continue;\n                if (!enumHasConstant(param, \"MAIN_HAND\") || !enumHasConstant(param, \"OFF_HAND\")) continue;\n\n                int score = 0;\n                if (m.getName().equals(\"startUsingItem\")) score += 100;\n                if (m.getName().equals(\"c\")) score += 50;\n                if (m.getName().equals(\"a\")) score += 40;\n                final String owner = m.getDeclaringClass().getSimpleName();\n                if (owner.contains(\"Living\")) score += 20;\n                if (owner.contains(\"Entity\")) score += 10;\n                if (score > bestScore) {\n                    bestScore = score;\n                    best = m;\n                }\n            }\n            current = current.getSuperclass();\n        }\n\n        if (best != null) {\n            best.setAccessible(true);\n        }\n        return best;\n    }\n\n    private boolean enumHasConstant(Class<?> enumClass, String name) {\n        return enumConstantByName(enumClass, name) != null;\n    }\n\n    private Object enumConstantByName(Class<?> enumClass, String name) {\n        try {\n            for (Object constant : enumClass.getEnumConstants()) {\n                if (constant instanceof Enum && ((Enum<?>) constant).name().equals(name)) {\n                    return constant;\n                }\n            }\n        } catch (Throwable ignored) {\n        }\n        return null;\n    }\n\n    private boolean stripConsumable(ItemStack item) {\n        if (!paperSupported || paperAdapter == null || paperClear == null || item == null) return false;\n        if (item.getType() == Material.AIR || !isHoldingSword(item.getType())) return false;\n        if (!hasConsumableComponent(item)) return false;\n        try {\n            paperClear.invoke(paperAdapter, item);\n            return true;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private boolean hasConsumableComponent(ItemStack item) {\n        if (!paperSupported || paperAdapter == null || paperHasConsumable == null || item == null) return false;\n        try {\n            final Object result = paperHasConsumable.invoke(paperAdapter, item);\n            return result instanceof Boolean && (Boolean) result;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private boolean shouldHandleConsumable(Player player) {\n        return paperSupported && paperAdapter != null && player != null && isEnabled(player) && isPaperAnimationEnabled();\n    }\n\n    private boolean isPaperAnimationEnabled() {\n        return module().getBoolean(\"paper-animation\", true);\n    }\n\n    private void sweepConsumableState(Player player, boolean includeStorage) {\n        if (!paperSupported || paperAdapter == null || player == null) return;\n\n        final PlayerInventory inv = player.getInventory();\n        final boolean enabled = isEnabled(player);\n        final boolean supportsAnimation = supportsPaperAnimation(player);\n\n        // Offhand should never carry the component.\n        final ItemStack off = inv.getItemInOffHand();\n        if (stripConsumable(off)) {\n            inv.setItemInOffHand(off);\n        }\n\n        final ItemStack main = inv.getItemInMainHand();\n        if (supportsAnimation && enabled) {\n            if (applyConsumableComponent(player, main)) {\n                inv.setItemInMainHand(main);\n            }\n            if (!includeStorage) {\n                return;\n            }\n\n            // Keep only the held slot eligible for the component.\n            final int held = inv.getHeldItemSlot();\n            final ItemStack[] storage = inv.getStorageContents();\n            for (int i = 0; i < storage.length; i++) {\n                if (i == held) continue;\n                final ItemStack item = storage[i];\n                if (stripConsumable(item)) {\n                    inv.setItem(i, item);\n                }\n            }\n            return;\n        }\n\n        if (stripConsumable(main)) {\n            inv.setItemInMainHand(main);\n        }\n\n        if (!includeStorage) {\n            return;\n        }\n\n        final ItemStack[] storage = inv.getStorageContents();\n        for (int i = 0; i < storage.length; i++) {\n            final ItemStack item = storage[i];\n            if (stripConsumable(item)) {\n                inv.setItem(i, item);\n            }\n        }\n    }\n\n    private void stripConsumableState(Player player, boolean includeStorage) {\n        if (!paperSupported || paperAdapter == null || player == null) return;\n\n        final PlayerInventory inv = player.getInventory();\n\n        final ItemStack main = inv.getItemInMainHand();\n        if (stripConsumable(main)) {\n            inv.setItemInMainHand(main);\n        }\n\n        final ItemStack off = inv.getItemInOffHand();\n        if (stripConsumable(off)) {\n            inv.setItemInOffHand(off);\n        }\n\n        if (!includeStorage) {\n            return;\n        }\n\n        final ItemStack[] storage = inv.getStorageContents();\n        for (int i = 0; i < storage.length; i++) {\n            final ItemStack item = storage[i];\n            if (stripConsumable(item)) {\n                inv.setItem(i, item);\n            }\n        }\n    }\n\n    private boolean isUnknownClientVersion(Object clientVersion) {\n        if (!(clientVersion instanceof Enum)) return false;\n        final String name = ((Enum<?>) clientVersion).name();\n        return \"UNKNOWN\".equals(name) || \"HIGHER_THAN_SUPPORTED_VERSIONS\".equals(name);\n    }\n\n    private class ConsumableLifecycleHandler implements Listener {\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onHeld(PlayerItemHeldEvent event) {\n            if (!shouldHandleConsumable(event.getPlayer()) || !supportsPaperAnimation(event.getPlayer())) return;\n            final PlayerInventory inv = event.getPlayer().getInventory();\n            final ItemStack prev = inv.getItem(event.getPreviousSlot());\n            if (stripConsumable(prev)) {\n                inv.setItem(event.getPreviousSlot(), prev);\n            }\n\n            final ItemStack next = inv.getItem(event.getNewSlot());\n            if (applyConsumableComponent(event.getPlayer(), next)) {\n                inv.setItem(event.getNewSlot(), next);\n            }\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onSwap(PlayerSwapHandItemsEvent event) {\n            final Player player = event.getPlayer();\n            if (!shouldHandleConsumable(player) || !supportsPaperAnimation(player)) return;\n            final int heldSlotAtEvent = player.getInventory().getHeldItemSlot();\n            final org.bukkit.inventory.InventoryView viewAtEvent = player.getOpenInventory();\n            final org.bukkit.inventory.Inventory eventTop = viewAtEvent == null ? null : viewAtEvent.getTopInventory();\n            final org.bukkit.inventory.Inventory eventBottom = viewAtEvent == null ? null : viewAtEvent.getBottomInventory();\n            // Apply/strip against the actual inventory after the swap has taken place.\n            Bukkit.getScheduler().runTask(plugin, () -> {\n                final PlayerInventory inv = player.getInventory();\n                final ItemStack main = inv.getItemInMainHand();\n                final ItemStack off = inv.getItemInOffHand();\n                final boolean mainStripped = stripConsumable(main);\n                final boolean offStripped = stripConsumable(off);\n                final org.bukkit.inventory.InventoryView currentView = player.getOpenInventory();\n                final boolean viewMatches = currentView != null\n                        && currentView.getTopInventory() == eventTop\n                        && currentView.getBottomInventory() == eventBottom;\n                final boolean mainApplied = shouldHandleConsumable(player)\n                        && supportsPaperAnimation(player)\n                        && inv.getHeldItemSlot() == heldSlotAtEvent\n                        && viewMatches\n                        && applyConsumableComponent(player, main);\n                if (mainStripped || mainApplied) {\n                    inv.setItemInMainHand(main);\n                }\n                if (offStripped) {\n                    inv.setItemInOffHand(off);\n                }\n            });\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onDrop(PlayerDropItemEvent event) {\n            if (!shouldHandleConsumable(event.getPlayer()) || !supportsPaperAnimation(event.getPlayer())) return;\n            stripConsumable(event.getItemDrop().getItemStack());\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onDeath(PlayerDeathEvent event) {\n            if (!shouldHandleConsumable(event.getEntity()) || !supportsPaperAnimation(event.getEntity())) return;\n            event.getDrops().forEach(ModuleSwordBlocking.this::stripConsumable);\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onQuit(PlayerQuitEvent event) {\n            stripConsumableState(event.getPlayer(), true);\n        }\n\n        @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)\n        public void onWorldChange(PlayerChangedWorldEvent event) {\n            stripConsumableState(event.getPlayer(), true);\n        }\n    }\n\n    private void ensureLegacyTaskRunning() {\n        if (legacyTask != null) return;\n        tickCounter = 0;\n        // Performance: previously, sword blocking could schedule per-player repeating tasks. When many players\n        // block at once this scales poorly (scheduler overhead + allocations). Instead, keep per-player state in\n        // a map and run one shared tick task while there is any active legacy blocking state.\n        legacyTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {\n            tickCounter++;\n            if (legacyStates.isEmpty()) {\n                stopLegacyTaskIfIdle();\n                return;\n            }\n\n            // Iterate over a snapshot to avoid ConcurrentModificationException if legacyStates is mutated by other\n            // events during this tick (quit/world change, additional right-clicks, etc.).\n            final List<UUID> uuids = new ArrayList<>(legacyStates.keySet());\n            for (UUID uuid : uuids) {\n                final LegacySwordBlockState state = legacyStates.get(uuid);\n                if (state == null) continue;\n                if (!storedItems.containsKey(uuid)) {\n                    continue;\n                }\n\n                final Player player = Bukkit.getPlayer(uuid);\n                if (player == null) {\n                    // Cannot restore cleanly; drop state and stored item reference.\n                    storedItems.remove(uuid);\n                    legacyStates.remove(uuid);\n                    continue;\n                }\n\n                // Mirror previous behaviour: after 10 ticks, poll every 2 ticks for stop-blocking.\n                if (tickCounter >= state.nextBlockingCheckTick) {\n                    if (!isPlayerBlocking(player)) {\n                        restore(player, false, true);\n                        legacyStates.remove(uuid);\n                        continue;\n                    }\n                    state.nextBlockingCheckTick += 2L;\n                }\n\n                // Restore-delay timeout: attempt restore; if still blocking, restore() reschedules.\n                if (tickCounter >= state.restoreAtTick) {\n                    restore(player, false, true);\n                    if (!storedItems.containsKey(uuid)) legacyStates.remove(uuid);\n                }\n            }\n\n            stopLegacyTaskIfIdle();\n        }, 1L, 1L);\n    }\n\n    private void stopLegacyTaskIfIdle() {\n        if (legacyTask == null) return;\n        if (!legacyStates.isEmpty()) return;\n        legacyTask.cancel();\n        legacyTask = null;\n    }\n\n    private static final class LegacySwordBlockState {\n        private long restoreAtTick;\n        private long nextBlockingCheckTick;\n    }\n\n    private static final class EntityInteractionKey {\n        private final UUID playerId;\n        private final UUID entityId;\n        private final EquipmentSlot hand;\n\n        private EntityInteractionKey(UUID playerId, UUID entityId, EquipmentSlot hand) {\n            this.playerId = playerId;\n            this.entityId = entityId;\n            this.hand = hand;\n        }\n\n        @Override\n        public boolean equals(Object o) {\n            if (this == o) return true;\n            if (o == null || getClass() != o.getClass()) return false;\n            final EntityInteractionKey that = (EntityInteractionKey) o;\n            return Objects.equals(playerId, that.playerId)\n                    && Objects.equals(entityId, that.entityId)\n                    && hand == that.hand;\n        }\n\n        @Override\n        public int hashCode() {\n            return Objects.hash(playerId, entityId, hand);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleSwordSweep.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport com.cryptomorin.xseries.XEnchantment;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage;\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.enchantments.Enchantment;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.UUID;\n\n/**\n * A module to disable the sweep attack.\n */\npublic class ModuleSwordSweep extends OCMModule {\n\n    // Legacy (pre-1.11) sweep detection: we observe a normal sword hit, then a 1.0 sweep-damage hit follows\n    // immediately afterwards from the same attacker. Track per-attacker, not by Location (yaw/pitch makes Location unstable).\n    private final Set<UUID> sweepPrimedAttackers = new HashSet<>();\n    private EntityDamageEvent.DamageCause sweepDamageCause;\n    private BukkitTask pendingClearTask;\n\n    public ModuleSwordSweep(OCMMain plugin) {\n        super(plugin, \"disable-sword-sweep\");\n\n        try {\n            // Available from 1.11 onwards\n            sweepDamageCause = EntityDamageEvent.DamageCause.valueOf(\"ENTITY_SWEEP_ATTACK\");\n        } catch (IllegalArgumentException e) {\n            sweepDamageCause = null;\n        }\n\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        // we didn't set anything up in the first place\n        if (sweepDamageCause != null) return;\n\n        if (pendingClearTask != null) {\n            pendingClearTask.cancel();\n            pendingClearTask = null;\n        }\n        sweepPrimedAttackers.clear();\n    }\n\n\n    //Changed from HIGHEST to LOWEST to support DamageIndicator plugin\n    @EventHandler(priority = EventPriority.LOWEST)\n    public void onEntityDamaged(EntityDamageByEntityEvent e) {\n        final Entity damager = e.getDamager();\n\n        if (!(damager instanceof Player)) return;\n        if (!isEnabled(damager, e.getEntity())) return;\n\n        if (sweepDamageCause != null) {\n            if (e.getCause() == sweepDamageCause) {\n                e.setCancelled(true);\n                debug(\"Sweep cancelled\", damager);\n            }\n            // sweep attack detected or not, we do not need to fall back to the guessing implementation\n            return;\n        }\n\n        final Player attacker = (Player) e.getDamager();\n        final ItemStack weapon = attacker.getInventory().getItemInMainHand();\n\n        if (isHoldingSword(weapon.getType()))\n            onSwordAttack(e, attacker, weapon);\n    }\n\n    private void onSwordAttack(EntityDamageByEntityEvent e, Player attacker, ItemStack weapon) {\n        //Disable sword sweep\n        int level = 0;\n\n        final Enchantment sweepingEdge = XEnchantment.SWEEPING_EDGE.getEnchant();\n        if (sweepingEdge != null) {\n            level = weapon.getEnchantmentLevel(sweepingEdge);\n        }\n\n        final Float baseDamage = NewWeaponDamage.getDamageOrNull(weapon.getType());\n        if (baseDamage == null) {\n            debug(\"Unknown sword in NewWeaponDamage: \" + weapon.getType() + \" (passing through)\", attacker);\n            return;\n        }\n        final float damage = baseDamage * level / (level + 1) + 1;\n\n        if (e.getDamage() == damage) {\n            // Possibly a sword-sweep attack\n            if (sweepPrimedAttackers.contains(attacker.getUniqueId())) {\n                debug(\"Cancelling sweep...\", attacker);\n                e.setCancelled(true);\n            }\n        } else {\n            sweepPrimedAttackers.add(attacker.getUniqueId());\n            scheduleClearNextTickIfNeeded();\n        }\n    }\n\n    private void scheduleClearNextTickIfNeeded() {\n        if (pendingClearTask != null) return;\n        pendingClearTask = Bukkit.getScheduler().runTaskLater(plugin, () -> {\n            sweepPrimedAttackers.clear();\n            pendingClearTask = null;\n        }, 1L);\n    }\n\n    private boolean isHoldingSword(Material mat) {\n        return mat.toString().endsWith(\"_SWORD\");\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleSwordSweepParticles.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport com.github.retrooper.packetevents.PacketEvents;\nimport com.github.retrooper.packetevents.event.PacketListenerAbstract;\nimport com.github.retrooper.packetevents.event.PacketSendEvent;\nimport com.github.retrooper.packetevents.protocol.packettype.PacketType;\nimport com.github.retrooper.packetevents.protocol.particle.Particle;\nimport com.github.retrooper.packetevents.protocol.particle.type.ParticleTypes;\nimport com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerParticle;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport org.bukkit.entity.Player;\n\n/**\n * A module to disable the sweep attack.\n */\npublic class ModuleSwordSweepParticles extends OCMModule {\n\n    private final ParticleListener particleListener = new ParticleListener();\n\n    public ModuleSwordSweepParticles(OCMMain plugin) {\n        super(plugin, \"disable-sword-sweep-particles\");\n\n        reload();\n    }\n\n    @Override\n    public void reload() {\n        if (isEnabled())\n            PacketEvents.getAPI().getEventManager().registerListener(particleListener);\n        else\n            PacketEvents.getAPI().getEventManager().unregisterListener(particleListener);\n    }\n\n    /**\n     * Hides sweep particles.\n     */\n    private class ParticleListener extends PacketListenerAbstract {\n\n        private boolean disabledDueToError;\n\n        @Override\n        public void onPacketSend(PacketSendEvent packetEvent) {\n            if (disabledDueToError || packetEvent.isCancelled())\n                return;\n\n            try {\n                if (!PacketType.Play.Server.PARTICLE.equals(packetEvent.getPacketType()))\n                    return;\n\n                final Object playerObject = packetEvent.getPlayer();\n                if (!(playerObject instanceof Player))\n                    return;\n\n                final Player player = (Player) playerObject;\n                if (!isEnabled(player))\n                    return;\n\n                WrapperPlayServerParticle wrapper = new WrapperPlayServerParticle(packetEvent);\n                Particle<?> particle = wrapper.getParticle();\n                if (particle == null || particle.getType() == null)\n                    return;\n\n                if (particle.getType() == ParticleTypes.SWEEP_ATTACK) {\n                    packetEvent.setCancelled(true);\n                    debug(\"Cancelled sweep particles\", player);\n                }\n            } catch (Exception | ExceptionInInitializerError e) {\n                disabledDueToError = true;\n                Messenger.warn(\n                        e,\n                        \"Error detecting sweep packets. Please report it along with the following exception \" +\n                                \"on github.\" +\n                                \"Sweep cancellation should still work, but particles might show up.\"\n                );\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/module/OCMModule.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.module;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;\nimport org.bukkit.World;\nimport org.bukkit.command.CommandSender;\nimport org.bukkit.configuration.ConfigurationSection;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.Listener;\nimport org.jetbrains.annotations.NotNull;\n\nimport java.util.Arrays;\nimport java.util.Locale;\nimport java.util.Set;\n\n/**\n * A module providing some specific functionality, e.g. restoring fishing rod knockback.\n */\npublic abstract class OCMModule implements Listener {\n\n    protected OCMMain plugin;\n\n    private final String configName;\n    private final String moduleName;\n\n    /**\n     * Creates a new module.\n     *\n     * @param plugin     the plugin instance\n     * @param configName the name of the module in the config\n     */\n    protected OCMModule(OCMMain plugin, String configName) {\n        this.plugin = plugin;\n        this.configName = configName;\n        this.moduleName = getClass().getSimpleName();\n    }\n\n    /**\n     * Checks whether this module is globally en/disabled.\n     *\n     * @return true if this module is globally enabled\n     */\n    public boolean isEnabled() {\n        return Config.moduleEnabled(configName, null);\n    }\n\n    /**\n     * Checks whether the module is present in the default modeset for the specified world\n     *\n     * @param world The world to get the default modeset for\n     * @return Whether the module is enabled for the found modeset in the given world\n     */\n    public boolean isEnabled(World world) {\n        return Config.moduleEnabled(configName, world);\n    }\n\n    /**\n     * Whether this module should be enabled for this player given his current modeset\n     */\n    public boolean isEnabled(@NotNull HumanEntity humanEntity) {\n        if (Config.isModuleDisabled(configName)) {\n            return false;\n        }\n        if (Config.isModuleAlwaysEnabled(configName)) {\n            return true;\n        }\n        final World world = humanEntity.getWorld();\n        final String modesetName = PlayerStorage.getPlayerData(humanEntity.getUniqueId()).getModesetForWorld(world.getUID());\n\n        if (modesetName == null) {\n            debug(\"No modeset found!\", humanEntity);\n            debug(\"No modeset found for \" + humanEntity.getName());\n            return isEnabled(world);\n        }\n\n        // Check if the modeset contains this module's name\n        final Set<String> modeset = Config.getModesets().get(modesetName);\n        return modeset != null && modeset.contains(configName);\n    }\n\n    public boolean isEnabled(@NotNull Entity entity) {\n        if (entity instanceof HumanEntity)\n            return isEnabled((HumanEntity) entity);\n        return isEnabled(entity.getWorld());\n    }\n\n    /**\n     * Returns if module should be enabled, giving priority to the attacker, if a human.\n     * If neither entity is a human, checks if module should be enabled in the defender's world.\n     *\n     * @param attacker The entity that is performing the attack\n     * @param defender The entity that is being attacked\n     * @return Whether the module should be enabled for this particular interaction\n     */\n    public boolean isEnabled(@NotNull Entity attacker, @NotNull Entity defender) {\n        if (attacker instanceof HumanEntity) return isEnabled((HumanEntity) attacker);\n        if (defender instanceof HumanEntity) return isEnabled((HumanEntity) defender);\n        return isEnabled(defender.getWorld());\n    }\n\n    /**\n     * Checks whether a given setting for this module is enabled.\n     *\n     * @param name the name of the setting\n     * @return true if the setting with that name is enabled. Returns false if the setting did not exist.\n     */\n    public boolean isSettingEnabled(String name) {\n        return plugin.getConfig().getBoolean(configName + \".\" + name, false);\n    }\n\n    /**\n     * Returns the configuration section for this module.\n     *\n     * @return the configuration section for this module\n     */\n    public ConfigurationSection module() {\n        return plugin.getConfig().getConfigurationSection(configName);\n    }\n\n    /**\n     * Called when the plugin is reloaded. Should re-read all relevant config keys and other resources that might have\n     * changed.\n     */\n    public void reload() {\n        // Intentionally left blank! Meant for individual modules to use.\n    }\n\n    /**\n     * Called when player changes modeset. Re-apply any more permanent changes\n     * depending on result of isEnabled(player).\n     *\n     * @param player The player that changed modeset\n     */\n    public void onModesetChange(Player player) {\n        // Intentionally left blank! Meant for individual modules to use.\n    }\n\n    /**\n     * Outputs a debug message.\n     *\n     * @param text the message text\n     */\n    protected void debug(String text) {\n        Messenger.debug(\"[\" + moduleName + \"] \" + text);\n    }\n\n    /**\n     * Sends a debug message to the given command sender.\n     *\n     * @param text   the message text\n     * @param sender the sender to send it to\n     */\n    protected void debug(String text, CommandSender sender) {\n        if (Config.debugEnabled()) {\n            Messenger.sendNoPrefix(sender, \"&8&l[&fDEBUG&8&l][&f\" + moduleName + \"&8&l]&7 \" + text);\n        }\n    }\n\n    @Override\n    public String toString() {\n        return Arrays.stream(configName.split(\"-\"))\n                .map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase(Locale.ROOT))\n                .reduce((a, b) -> a + \" \" + b)\n                .orElse(configName);\n    }\n\n    /**\n     * Get the module's name, as taken from the class name\n     *\n     * @return The module name, e.g. ModuleDisableAttackCooldown\n     */\n    public String getModuleName() {\n        return moduleName;\n    }\n\n    public String getConfigName() {\n        return configName;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/paper/PaperSwordBlocking.java",
    "content": "package kernitus.plugin.OldCombatMechanics.paper;\n\nimport java.lang.invoke.MethodHandle;\nimport java.lang.invoke.MethodHandles;\nimport java.lang.reflect.Field;\nimport java.lang.reflect.Method;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\nimport org.bukkit.Bukkit;\nimport org.bukkit.Material;\nimport org.bukkit.entity.Player;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.inventory.PlayerInventory;\n\n/**\n * Paper-only helper: NMS reflection-based to avoid linking Paper API at compile time.\n *\n * - Patch the underlying NMS ItemStack (DataComponents.CONSUMABLE) so the item is actually usable and the server\n *   can drive the \"active item\"/hand-raised state.\n *\n * Performance: all reflective lookups are done once in the constructor; hot calls only use cached MethodHandles.\n * We deliberately swallow failures here: if Paper changes internals, we prefer \"legacy fallback\" over hard failing.\n */\npublic class PaperSwordBlocking {\n\n    private static final float BLOCK_CONSUME_SECONDS = 1.6f;\n\n    private final MethodHandle nmsApplyComponents;\n    private final MethodHandle nmsSetComponent;\n    private final MethodHandle nmsRemoveComponent;\n    private final MethodHandle nmsGetComponentsPatch;\n    private final MethodHandle nmsPatchGet;\n    private final Object addConsumablePatch;\n    private final Object removeConsumablePatch;\n    private final Object nmsConsumableType;\n    private final Object nmsConsumableComponent;\n    private final Method paperSetDataValue;\n    private final Method paperSetDataBuilder;\n    private final Method paperUnsetData;\n    private final Method paperHasData;\n    private final Method paperEnsureServerConversions;\n    private final Method paperCopyDataFrom;\n    private final Object paperConsumableType;\n    private final Object paperConsumableValue;\n    private final Object paperBaseConsumable;\n    private final Method paperBaseConsumableToBuilder;\n    private final Method paperConsumableBuilderFactory;\n    private final Class<?> paperUseAnimClass;\n    private final Object paperBlockAnimation;\n    private final Class<?> paperValuedTypeClass;\n    private final Class<?> paperBuilderClass;\n    private static final Predicate<Object> COPY_ALL_COMPONENTS = ignored -> true;\n    private volatile Field craftItemStackHandleField;\n\n    private final MethodHandle craftPlayerGetHandle;\n    private final MethodHandle nmsGetUseItem;\n    private final MethodHandle nmsItemStackIs;\n    private final Object nmsSwordTag;\n\n    public PaperSwordBlocking() throws Exception {\n        // Build the NMS consumable component: Consumable.builder().consumeSeconds(MAX).animation(BLOCK).build()\n        final Class<?> nmsConsumable = Class.forName(\"net.minecraft.world.item.component.Consumable\");\n        final Object nmsConsumableBuilder = nmsConsumable.getMethod(\"builder\").invoke(null);\n        final Class<?> nmsUseAnim = Class.forName(\"net.minecraft.world.item.ItemUseAnimation\");\n        final Object nmsBlockAnim = nmsUseAnim.getField(\"BLOCK\").get(null);\n        final Object nmsConsumableValue = nmsConsumableBuilder.getClass().getMethod(\"consumeSeconds\", float.class)\n            .invoke(nmsConsumableBuilder, Float.MAX_VALUE);\n        final Object nmsConsumableValue2 = nmsConsumableValue.getClass().getMethod(\"animation\", nmsUseAnim)\n            .invoke(nmsConsumableValue, nmsBlockAnim);\n        final Object consumableComponent = nmsConsumableValue2.getClass().getMethod(\"build\").invoke(nmsConsumableValue2);\n        nmsConsumableComponent = consumableComponent;\n\n        // Build patches once:\n        // DataComponentPatch.builder().set(DataComponents.CONSUMABLE, consumableComponent).build()\n        // DataComponentPatch.builder().remove(DataComponents.CONSUMABLE).build()\n        final Class<?> nmsDataComponents = Class.forName(\"net.minecraft.core.component.DataComponents\");\n        final Object consumableType = nmsDataComponents.getField(\"CONSUMABLE\").get(null);\n        nmsConsumableType = consumableType;\n        final Class<?> nmsPatch = Class.forName(\"net.minecraft.core.component.DataComponentPatch\");\n        final Object patchBuilderAdd = nmsPatch.getMethod(\"builder\").invoke(null);\n        final Method setMethod = findPatchSetMethod(patchBuilderAdd.getClass());\n        setMethod.invoke(patchBuilderAdd, consumableType, consumableComponent);\n        addConsumablePatch = patchBuilderAdd.getClass().getMethod(\"build\").invoke(patchBuilderAdd);\n\n        final Object patchBuilderRemove = nmsPatch.getMethod(\"builder\").invoke(null);\n        final Method removeMethod = findPatchRemoveMethod(patchBuilderRemove.getClass());\n        removeMethod.invoke(patchBuilderRemove, consumableType);\n        removeConsumablePatch = patchBuilderRemove.getClass().getMethod(\"build\").invoke(patchBuilderRemove);\n\n        Method setDataValueHandle = null;\n        Method setDataBuilderHandle = null;\n        Method unsetDataHandle = null;\n        Method hasDataHandle = null;\n        Method ensureServerConversionsHandle = null;\n        Method copyDataFromHandle = null;\n        Object consumableTypePaper = null;\n        Object consumableValuePaper = null;\n        Object baseConsumablePaper = null;\n        Method baseConsumableToBuilder = null;\n        Method consumableBuilderFactory = null;\n        Class<?> useAnimClass = null;\n        Object blockAnimation = null;\n        Class<?> valuedTypeClass = null;\n        Class<?> builderClass = null;\n        try {\n            final Class<?> paperItemStack = Class.forName(\"org.bukkit.inventory.ItemStack\");\n            final Class<?> paperDataComponentType = Class.forName(\"io.papermc.paper.datacomponent.DataComponentType\");\n            final Class<?> paperDataComponentTypeValued = Class.forName(\"io.papermc.paper.datacomponent.DataComponentType$Valued\");\n            final Class<?> paperDataComponentBuilder = Class.forName(\"io.papermc.paper.datacomponent.DataComponentBuilder\");\n            valuedTypeClass = paperDataComponentTypeValued;\n            builderClass = paperDataComponentBuilder;\n            final Class<?> paperDataComponentTypes = Class.forName(\"io.papermc.paper.datacomponent.DataComponentTypes\");\n            consumableTypePaper = paperDataComponentTypes.getField(\"CONSUMABLE\").get(null);\n\n            final Method getData = paperItemStack.getMethod(\"getData\", paperDataComponentTypeValued);\n            final Object bread = new ItemStack(Material.BREAD);\n            baseConsumablePaper = getData.invoke(bread, consumableTypePaper);\n            useAnimClass = Class.forName(\"io.papermc.paper.datacomponent.item.consumable.ItemUseAnimation\");\n            blockAnimation = useAnimClass.getField(\"BLOCK\").get(null);\n            if (baseConsumablePaper != null) {\n                baseConsumableToBuilder = baseConsumablePaper.getClass().getMethod(\"toBuilder\");\n            }\n            final Class<?> paperConsumable = Class.forName(\"io.papermc.paper.datacomponent.item.Consumable\");\n            consumableBuilderFactory = paperConsumable.getMethod(\"consumable\");\n\n            Method setDataMethodValue = null;\n            Method setDataMethodBuilder = null;\n            for (Method m : paperItemStack.getMethods()) {\n                if (!m.getName().equals(\"setData\")) continue;\n                if (m.getParameterCount() != 2) continue;\n                if (!paperDataComponentTypeValued.isAssignableFrom(m.getParameterTypes()[0])) continue;\n                if (paperDataComponentBuilder.isAssignableFrom(m.getParameterTypes()[1])) {\n                    setDataMethodBuilder = m;\n                    continue;\n                }\n                setDataMethodValue = m;\n            }\n\n            if (setDataMethodValue != null || setDataMethodBuilder != null) {\n                final Method unset = paperItemStack.getMethod(\"unsetData\", paperDataComponentType);\n                final Method has = paperItemStack.getMethod(\"hasData\", paperDataComponentType);\n                final Method ensure = paperItemStack.getMethod(\"ensureServerConversions\");\n                final Method copyFrom = paperItemStack.getMethod(\"copyDataFrom\", paperItemStack, Predicate.class);\n                if (setDataMethodValue != null) {\n                    setDataMethodValue.setAccessible(true);\n                }\n                if (setDataMethodBuilder != null) {\n                    setDataMethodBuilder.setAccessible(true);\n                }\n                unset.setAccessible(true);\n                has.setAccessible(true);\n                ensure.setAccessible(true);\n                copyFrom.setAccessible(true);\n                setDataValueHandle = setDataMethodValue;\n                setDataBuilderHandle = setDataMethodBuilder;\n                unsetDataHandle = unset;\n                hasDataHandle = has;\n                ensureServerConversionsHandle = ensure;\n                copyDataFromHandle = copyFrom;\n            }\n        } catch (Throwable ignored) {\n            consumableTypePaper = null;\n            consumableValuePaper = null;\n            baseConsumablePaper = null;\n            baseConsumableToBuilder = null;\n            consumableBuilderFactory = null;\n            useAnimClass = null;\n            blockAnimation = null;\n        }\n\n        paperSetDataValue = setDataValueHandle;\n        paperSetDataBuilder = setDataBuilderHandle;\n        paperUnsetData = unsetDataHandle;\n        paperHasData = hasDataHandle;\n        paperEnsureServerConversions = ensureServerConversionsHandle;\n        paperCopyDataFrom = copyDataFromHandle;\n        paperConsumableType = consumableTypePaper;\n        paperConsumableValue = consumableValuePaper;\n        paperBaseConsumable = baseConsumablePaper;\n        paperBaseConsumableToBuilder = baseConsumableToBuilder;\n        paperConsumableBuilderFactory = consumableBuilderFactory;\n        paperUseAnimClass = useAnimClass;\n        paperBlockAnimation = blockAnimation;\n        paperValuedTypeClass = valuedTypeClass;\n        paperBuilderClass = builderClass;\n\n        // NMS ItemStack#applyComponents(DataComponentPatch)\n        final Class<?> nmsItemStack = Class.forName(\"net.minecraft.world.item.ItemStack\");\n        final Method apply = nmsItemStack.getMethod(\"applyComponents\", nmsPatch);\n        final MethodHandles.Lookup lookup = MethodHandles.publicLookup();\n        nmsApplyComponents = lookup.unreflect(apply);\n\n        final Class<?> nmsComponentType = Class.forName(\"net.minecraft.core.component.DataComponentType\");\n        nmsSetComponent = lookup.unreflect(findItemStackSetMethod(nmsItemStack, nmsComponentType));\n        nmsRemoveComponent = lookup.unreflect(findItemStackRemoveMethod(nmsItemStack, nmsComponentType));\n\n        nmsGetComponentsPatch = lookup.unreflect(nmsItemStack.getMethod(\"getComponentsPatch\"));\n        nmsPatchGet = lookup.unreflect(nmsPatch.getMethod(\"get\", nmsComponentType));\n\n        // Detect sword blocking via active item use:\n        // player.getUseItem().is(ItemTags.SWORDS)\n        // (Bukkit's Player#isBlocking and #isHandRaised are shield-biased and do not reliably track the\n        // consumable-based sword use animation across server/client combinations.)\n        final Class<?> craftPlayer = Class.forName(\"org.bukkit.craftbukkit.entity.CraftPlayer\");\n        craftPlayerGetHandle = lookup.unreflect(craftPlayer.getMethod(\"getHandle\"));\n\n        final Class<?> nmsPlayer = Class.forName(\"net.minecraft.world.entity.player.Player\");\n        nmsGetUseItem = lookup.unreflect(nmsPlayer.getMethod(\"getUseItem\"));\n\n        final Object swordTag = Class.forName(\"net.minecraft.tags.ItemTags\").getField(\"SWORDS\").get(null);\n        nmsSwordTag = swordTag;\n        nmsItemStackIs = lookup.unreflect(findItemStackIsMethod(nmsItemStack, swordTag));\n    }\n\n    private Field resolveCraftItemStackHandleField(ItemStack stack) throws NoSuchFieldException {\n        final Field cached = craftItemStackHandleField;\n        if (cached != null) return cached;\n\n        Class<?> c = stack.getClass();\n        while (c != null && c != Object.class) {\n            try {\n                final Field f = c.getDeclaredField(\"handle\");\n                f.setAccessible(true);\n                craftItemStackHandleField = f;\n                return f;\n            } catch (NoSuchFieldException ignored) {\n                c = c.getSuperclass();\n            }\n        }\n        throw new NoSuchFieldException(\"No 'handle' field found on \" + stack.getClass().getName());\n    }\n\n    private Method findPatchSetMethod(Class<?> builderClass) throws NoSuchMethodException {\n        for (Method m : builderClass.getMethods()) {\n            if (!m.getName().equals(\"set\")) continue;\n            if (m.getParameterCount() != 2) continue;\n            return m;\n        }\n        throw new NoSuchMethodException(\"DataComponentPatch.Builder#set(type, value) not found\");\n    }\n\n    private Method findPatchRemoveMethod(Class<?> builderClass) throws NoSuchMethodException {\n        for (Method m : builderClass.getMethods()) {\n            if (!m.getName().equals(\"remove\")) continue;\n            if (m.getParameterCount() != 1) continue;\n            return m;\n        }\n        throw new NoSuchMethodException(\"DataComponentPatch.Builder#remove(type) not found\");\n    }\n\n    private Method findItemStackIsMethod(Class<?> nmsItemStackClass, Object tagInstance) throws NoSuchMethodException {\n        for (Method m : nmsItemStackClass.getMethods()) {\n            if (!m.getName().equals(\"is\")) continue;\n            if (m.getParameterCount() != 1) continue;\n            if (m.getReturnType() != boolean.class) continue;\n            final Class<?> param = m.getParameterTypes()[0];\n            // NMS has multiple \"is(...)\" overloads. We specifically need the tag overload used by\n            // ItemStack#is(ItemTags.SWORDS). Pick an overload whose parameter can accept the tag object.\n            if (tagInstance != null && param.isInstance(tagInstance)) {\n                return m;\n            }\n            if (tagInstance != null && param.isAssignableFrom(tagInstance.getClass())) {\n                return m;\n            }\n            // Heuristic fallback: TagKey / HolderSet based overloads tend to include \"TagKey\" in their type name.\n            if (param.getName().contains(\"TagKey\")) {\n                return m;\n            }\n        }\n        throw new NoSuchMethodException(\"ItemStack#is(tag) not found\");\n    }\n\n    public void applyComponents(ItemStack stack) {\n        applyComponentsInternal(stack, true);\n    }\n\n    private void applyComponentsInternal(ItemStack stack, boolean allowTestSync) {\n        if (stack == null || stack.getType() == Material.AIR || !isSword(stack.getType())) return;\n        boolean applied = false;\n        if (paperSetDataValue != null && paperConsumableType != null) {\n            final Object value = paperConsumableValue != null ? paperConsumableValue : buildPaperConsumableValue();\n            if (value != null) {\n                try {\n                    applied = setPaperDataValue(stack, value);\n                } catch (Throwable ignored) {\n                    applied = false;\n                }\n            }\n        }\n        if (!applied && paperSetDataBuilder != null && paperConsumableType != null) {\n            final Object builder = buildPaperConsumableBuilder();\n            if (builder != null) {\n                try {\n                    applied = setPaperDataBuilder(stack, builder);\n                } catch (Throwable ignored) {\n                    applied = false;\n                }\n            }\n        }\n        if (!applied && paperConsumableType != null && paperValuedTypeClass != null) {\n            final Object value = buildPaperConsumableValue();\n            if (value != null) {\n                applied = setPaperDataValue(stack, value);\n            }\n        }\n        if (!applied && paperConsumableType != null && paperBuilderClass != null) {\n            final Object builder = buildPaperConsumableBuilder();\n            if (builder != null) {\n                applied = setPaperDataBuilder(stack, builder);\n            }\n        }\n        if (applied) {\n            ensureServerConversions(stack);\n            if (allowTestSync) {\n                syncTestInventories(stack);\n            }\n            return;\n        }\n        try {\n            final Field handleField = resolveCraftItemStackHandleField(stack);\n            final Object nms = handleField.get(stack);\n            if (nms != null) {\n                nmsSetComponent.invoke(nms, nmsConsumableType, nmsConsumableComponent);\n            }\n            ensureServerConversions(stack);\n            if (allowTestSync) {\n                syncTestInventories(stack);\n            }\n        } catch (Throwable ignored) {\n        }\n    }\n\n    public void clearComponents(ItemStack stack) {\n        if (stack == null) return;\n        boolean cleared = false;\n        if (paperUnsetData != null && paperConsumableType != null) {\n            try {\n                paperUnsetData.invoke(stack, paperConsumableType);\n                cleared = true;\n            } catch (Throwable ignored) {\n                cleared = false;\n            }\n        }\n        if (cleared) {\n            ensureServerConversions(stack);\n            return;\n        }\n        try {\n            final Field handleField = resolveCraftItemStackHandleField(stack);\n            final Object nms = handleField.get(stack);\n            if (nms != null) {\n                nmsRemoveComponent.invoke(nms, nmsConsumableType);\n            }\n            ensureServerConversions(stack);\n        } catch (Throwable ignored) {\n        }\n    }\n\n    private Method findItemStackSetMethod(Class<?> nmsItemStackClass, Class<?> nmsComponentTypeClass) throws NoSuchMethodException {\n        for (Method m : nmsItemStackClass.getMethods()) {\n            if (!m.getName().equals(\"set\")) continue;\n            if (m.getParameterCount() != 2) continue;\n            if (!m.getParameterTypes()[0].isAssignableFrom(nmsComponentTypeClass)) continue;\n            return m;\n        }\n        throw new NoSuchMethodException(\"ItemStack#set(component, value) not found\");\n    }\n\n    private Method findItemStackRemoveMethod(Class<?> nmsItemStackClass, Class<?> nmsComponentTypeClass) throws NoSuchMethodException {\n        for (Method m : nmsItemStackClass.getMethods()) {\n            if (!m.getName().equals(\"remove\")) continue;\n            if (m.getParameterCount() != 1) continue;\n            if (!m.getParameterTypes()[0].isAssignableFrom(nmsComponentTypeClass)) continue;\n            return m;\n        }\n        throw new NoSuchMethodException(\"ItemStack#remove(component) not found\");\n    }\n\n    public boolean hasConsumableComponent(ItemStack stack) {\n        if (stack == null || stack.getType() == Material.AIR || !isSword(stack.getType())) return false;\n        if (paperHasData != null && paperConsumableType != null) {\n            try {\n                final Object result = paperHasData.invoke(stack, paperConsumableType);\n                if (result instanceof Boolean) {\n                    return (Boolean) result;\n                }\n            } catch (Throwable ignored) {\n            }\n        }\n        try {\n            final Field handleField = resolveCraftItemStackHandleField(stack);\n            final Object nms = handleField.get(stack);\n            if (nms == null) return false;\n            final Object patch = nmsGetComponentsPatch.invoke(nms);\n            if (patch == null) return false;\n            final Object entry = nmsPatchGet.invoke(patch, nmsConsumableType);\n            if (!(entry instanceof Optional)) return false;\n            return ((Optional<?>) entry).isPresent();\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private void ensureServerConversions(ItemStack stack) {\n        if (paperEnsureServerConversions == null || paperCopyDataFrom == null || stack == null) return;\n        try {\n            final Object converted = paperEnsureServerConversions.invoke(stack);\n            if (!(converted instanceof ItemStack)) return;\n            if (converted == stack) return;\n            paperCopyDataFrom.invoke(stack, converted, COPY_ALL_COMPONENTS);\n        } catch (Throwable ignored) {\n        }\n    }\n\n    private boolean setPaperDataValue(ItemStack stack, Object value) {\n        if (stack == null || value == null || paperConsumableType == null) return false;\n        try {\n            Method method = paperSetDataValue;\n            if (method == null && paperValuedTypeClass != null) {\n                method = stack.getClass().getMethod(\"setData\", paperValuedTypeClass, Object.class);\n            }\n            if (method == null) return false;\n            method.invoke(stack, paperConsumableType, value);\n            return true;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private boolean setPaperDataBuilder(ItemStack stack, Object builder) {\n        if (stack == null || builder == null || paperConsumableType == null || paperBuilderClass == null) return false;\n        try {\n            Method method = paperSetDataBuilder;\n            if (method == null) {\n                method = stack.getClass().getMethod(\"setData\", paperValuedTypeClass, paperBuilderClass);\n            }\n            if (method == null) return false;\n            method.invoke(stack, paperConsumableType, builder);\n            return true;\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private Object buildPaperConsumableBuilder() {\n        if (paperConsumableType == null || paperUseAnimClass == null || paperBlockAnimation == null) return null;\n        try {\n            Object builder = null;\n            boolean fromBase = false;\n            if (paperBaseConsumable != null && paperBaseConsumableToBuilder != null) {\n                builder = paperBaseConsumableToBuilder.invoke(paperBaseConsumable);\n                fromBase = true;\n            }\n            if (builder == null && paperConsumableBuilderFactory != null) {\n                builder = paperConsumableBuilderFactory.invoke(null);\n                fromBase = false;\n            }\n            if (builder == null) return null;\n            Object configured = builder;\n            if (!fromBase) {\n                configured = configured.getClass().getMethod(\"consumeSeconds\", float.class).invoke(configured, BLOCK_CONSUME_SECONDS);\n            }\n            final Object withAnim = configured.getClass().getMethod(\"animation\", paperUseAnimClass).invoke(configured, paperBlockAnimation);\n            return withAnim;\n        } catch (Throwable ignored) {\n            return null;\n        }\n    }\n\n    private Object buildPaperConsumableValue() {\n        final Object builder = buildPaperConsumableBuilder();\n        if (builder == null) return null;\n        try {\n            return builder.getClass().getMethod(\"build\").invoke(builder);\n        } catch (Throwable ignored) {\n            return null;\n        }\n    }\n\n    private void syncTestInventories(ItemStack stack) {\n        if (!isTestServer() || stack == null) return;\n        for (Player player : Bukkit.getOnlinePlayers()) {\n            final PlayerInventory inventory = player.getInventory();\n            final int size = inventory.getSize();\n            for (int slot = 0; slot < size; slot++) {\n                final ItemStack candidate = inventory.getItem(slot);\n                if (candidate == null) continue;\n                if (!candidate.isSimilar(stack)) continue;\n                applyComponentsInternal(candidate, false);\n                inventory.setItem(slot, candidate);\n            }\n\n            final ItemStack cursor = player.getItemOnCursor();\n            if (cursor != null && cursor.isSimilar(stack)) {\n                applyComponentsInternal(cursor, false);\n                player.setItemOnCursor(cursor);\n            }\n        }\n    }\n\n    private boolean isTestServer() {\n        return Bukkit.getPluginManager().getPlugin(\"OldCombatMechanicsTest\") != null;\n    }\n\n    public boolean isBlockingSword(Player player) {\n        if (player == null) return false;\n        try {\n            final Object nmsPlayer = craftPlayerGetHandle.invoke(player);\n            if (nmsPlayer == null) return false;\n            final Object useItem = nmsGetUseItem.invoke(nmsPlayer);\n            if (useItem == null) return false;\n            return (boolean) nmsItemStackIs.invoke(useItem, nmsSwordTag);\n        } catch (Throwable ignored) {\n            return false;\n        }\n    }\n\n    private boolean isSword(Material mat) {\n        return mat != null && mat.name().endsWith(\"_SWORD\");\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/updater/ModuleUpdateChecker.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.updater;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.UpdateChecker;\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport org.bukkit.Bukkit;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.player.PlayerJoinEvent;\n\npublic class ModuleUpdateChecker extends OCMModule {\n\n    public ModuleUpdateChecker(OCMMain plugin) {\n        super(plugin, \"update-checker\");\n    }\n\n    @EventHandler\n    public void onPlayerLogin(PlayerJoinEvent e) {\n        final Player player = e.getPlayer();\n        if (player.hasPermission(\"OldCombatMechanics.notify\"))\n            Bukkit.getScheduler().runTaskLaterAsynchronously(plugin,\n                    () -> new UpdateChecker(plugin).performUpdate(), 20L);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/updater/SpigetUpdateChecker.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.updater;\n\nimport com.google.gson.Gson;\nimport com.google.gson.JsonSyntaxException;\nimport com.google.gson.reflect.TypeToken;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\n\nimport java.io.*;\nimport java.lang.reflect.Type;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\nimport java.nio.channels.Channels;\nimport java.nio.channels.FileChannel;\nimport java.nio.channels.ReadableByteChannel;\nimport java.util.Collections;\nimport java.util.List;\n\n/**\n * Checks <a href=\"https://spiget.org\">Spiget</a> for updates.\n */\npublic class SpigetUpdateChecker {\n\n    private static final String USER_AGENT = \"OldCombatMechanics\";\n    private static final String VERSIONS_URL = \"https://api.spiget.org/v2/resources/19510/versions?size=15000\";\n    private static final String UPDATES_URL = \"https://api.spiget.org/v2/resources/19510/updates?size=15000\";\n    private static final String UPDATE_URL = \"https://www.spigotmc.org/resources/oldcombatmechanics.19510/update?update=\";\n    private static final String DOWNLOAD_URL = \"https://api.spiget.org/v2/resources/19510/download\";\n    private String latestVersion = \"\";\n\n    /**\n     * Returns whether an update is available.\n     *\n     * @return true if an update is available\n     */\n    public boolean isUpdateAvailable() {\n        try {\n            final List<VersionPojo> versions = getVersions(VERSIONS_URL);\n\n            if (versions.isEmpty()) return false;\n\n            final VersionPojo currentVersion = versions.get(versions.size() - 1);\n            latestVersion = currentVersion.getName();\n\n            return VersionChecker.shouldUpdate(latestVersion);\n        } catch (Exception e) {\n            e.printStackTrace();\n            return false;\n        }\n    }\n\n    /**\n     * Returns the URL for the update.\n     *\n     * @return URL for the update\n     */\n    public String getUpdateURL() {\n        try {\n            final List<VersionPojo> versions = getVersions(UPDATES_URL);\n\n            if (versions.isEmpty()) return \"Error getting update URL\";\n\n            final VersionPojo currentVersion = versions.get(versions.size() - 1);\n\n            return UPDATE_URL + currentVersion.getId();\n        } catch (Exception e) {\n            return \"Error getting update URL\";\n        }\n    }\n\n    /**\n     * Returns the latest found version. Only populated after a call to {@link #isUpdateAvailable()}.\n     *\n     * @return the latest found version\n     */\n    public String getLatestVersion() {\n        return latestVersion;\n    }\n\n    /**\n     * Downloads the latest version of the plugin to the specified location.\n     *\n     * @param updateFolderFile The location of the server's plugin update folder\n     * @param fileName         The name of the JAR file to be updated\n     * @return Whether the file was downloaded successfully or not\n     */\n    public boolean downloadLatestVersion(File updateFolderFile, String fileName) {\n        updateFolderFile.mkdirs(); // Create all parent directories if required\n        File downloadFile = new File(updateFolderFile, fileName);\n\n        try {\n            final HttpURLConnection connection = (HttpURLConnection) new URL(DOWNLOAD_URL).openConnection();\n            connection.addRequestProperty(\"User-Agent\", USER_AGENT);\n\n            try (FileOutputStream fileOutputStream = new FileOutputStream(downloadFile);\n                 final ReadableByteChannel readableByteChannel = Channels.newChannel(connection.getInputStream());\n                 final FileChannel fileChannel = fileOutputStream.getChannel();\n            ) {\n                // Use NIO for better performance\n                fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE);\n            } catch (Exception e) {\n                downloadFile.delete(); // Remove downloaded file is something went wrong\n                throw new RuntimeException(e); // Rethrow exception to catch in outer scope\n            }\n        } catch (IOException e) {\n            Messenger.warn(\"Tried to download plugin update, but an error occurred\");\n            e.printStackTrace();\n            return false;\n        }\n        return true;\n    }\n\n    /**\n     * Returns all versions.\n     *\n     * @param urlString the url to read the json from\n     * @return a list with all found versions\n     */\n    private List<VersionPojo> getVersions(String urlString) {\n        try {\n            final InputStreamReader reader = fetchPage(urlString);\n\n            final Type pojoType = new TypeToken<List<VersionPojo>>() {\n            }.getType();\n\n            final List<VersionPojo> parsedVersions = new Gson().fromJson(reader, pojoType);\n\n            if (parsedVersions == null) {\n                System.err.println(\"JSON was at EOF when checking for spiget updates!\");\n                return Collections.emptyList();\n            }\n\n            return parsedVersions;\n        } catch (JsonSyntaxException | IOException e) {\n            e.printStackTrace();\n            return Collections.emptyList();\n        }\n    }\n\n    private InputStreamReader fetchPage(String urlString) throws IOException {\n        final URL url = new URL(urlString);\n        final HttpURLConnection connection = (HttpURLConnection) url.openConnection();\n        connection.addRequestProperty(\"User-Agent\", USER_AGENT);\n        connection.setConnectTimeout(10000); // 10 seconds\n        connection.setReadTimeout(10000); // 10 seconds\n\n        try {\n            int status = connection.getResponseCode();\n            if (status == HttpURLConnection.HTTP_OK) {\n                InputStream inputStream = connection.getInputStream();\n                return new InputStreamReader(inputStream);\n            } else {\n                // Log error or handle other status codes appropriately\n                throw new IOException(\"Server returned non-OK status: \" + status);\n            }\n        } catch (IOException e) {\n            // Log exception with as much detail as possible\n            throw e;\n        }\n    }\n\n    /**\n     * A pojo for a version returned by Spiget.\n     */\n    private static class VersionPojo {\n        // Created by GSON\n        @SuppressWarnings(\"unused\")\n        private String name;\n        @SuppressWarnings(\"unused\")\n        private String id;\n\n        /**\n         * The name. Might be null, if this was an update request.\n         *\n         * @return the name of this version\n         */\n        String getName() {\n            return name;\n        }\n\n        /**\n         * The id of this version.\n         *\n         * @return the id of this version\n         */\n        String getId() {\n            return id;\n        }\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/updater/VersionChecker.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.updater;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\npublic class VersionChecker {\n\n    public static boolean shouldUpdate(String remoteVersion) {\n        return isUpdateOut(remoteVersion, OCMMain.getVersion());\n    }\n\n    private static boolean isUpdateOut(String remoteVersion, String localVersion) {\n        final int[] testVer = getVersionNumbers(remoteVersion);\n        final int[] baseVer = getVersionNumbers(localVersion);\n\n        for (int i = 0; i < testVer.length; i++) {\n            if (testVer[i] != baseVer[i])\n                return testVer[i] > baseVer[i];\n        }\n\n        return false;\n    }\n\n    private static int[] getVersionNumbers(String ver) {\n        // Support both -beta and -SNAPSHOT\n        Matcher m = Pattern\n                .compile(\"(\\\\d+)\\\\.(\\\\d+)(?:\\\\.(\\\\d+))?(?:-(beta|SNAPSHOT)(\\\\d*))?\", Pattern.CASE_INSENSITIVE)\n                .matcher(ver);\n        if (!m.matches())\n            throw new IllegalArgumentException(\"Plugin version formatted wrong!\");\n\n        return new int[] {\n                Integer.parseInt(m.group(1)), // MAJOR\n                Integer.parseInt(m.group(2)), // MINOR\n                m.group(3) == null ? 0 : Integer.parseInt(m.group(3)), // PATCH (default 0)\n                m.group(4) == null ? Integer.MAX_VALUE : // Release version\n                        (m.group(5).isEmpty() ? 0 : Integer.parseInt(m.group(5))) // Pre-release number\n        };\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/Config.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\nimport kernitus.plugin.OldCombatMechanics.ModuleLoader;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.EntityDamageByEntityListener;\nimport kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages;\nimport org.bukkit.Bukkit;\nimport org.bukkit.World;\nimport org.bukkit.configuration.ConfigurationSection;\nimport org.bukkit.configuration.file.FileConfiguration;\nimport org.bukkit.configuration.file.YamlConfiguration;\n\nimport javax.annotation.Nullable;\nimport java.io.InputStreamReader;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.stream.Collectors;\n\npublic class Config {\n\n    private static final String CONFIG_NAME = \"config.yml\";\n    private static OCMMain plugin;\n    private static FileConfiguration config;\n    private static final Map<String, Set<String>> modesets = new HashMap<>();\n    private static final Map<UUID, Set<String>> worlds = new HashMap<>();\n    private static final Set<String> alwaysEnabledModules = new HashSet<>();\n    private static final Set<String> disabledModules = new HashSet<>();\n    private static final Set<String> optionalModules = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(\n            \"disable-attack-sounds\",\n            \"disable-sword-sweep-particles\"\n    )));\n    private static final Set<String> internalModules = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(\n            \"modeset-listener\",\n            \"attack-cooldown-tracker\",\n            \"entity-damage-listener\"\n    )));\n\n    public static void initialise(OCMMain plugin) {\n        Config.plugin = plugin;\n        config = plugin.getConfig();\n        // Make sure to separately call reload()\n    }\n\n    /**\n     * @return Whether config was changed or not\n     */\n    private static boolean checkConfigVersion() {\n        final YamlConfiguration defaultConfig = YamlConfiguration.loadConfiguration(\n                new InputStreamReader(Objects.requireNonNull(plugin.getResource(CONFIG_NAME))));\n\n        if (config.getInt(\"config-version\") != defaultConfig.getInt(\"config-version\")) {\n            plugin.upgradeConfig();\n            reload();\n            return true;\n        }\n\n        return false;\n    }\n\n\n    public static void reload() {\n        if (plugin.doesConfigExist()) {\n            plugin.reloadConfig();\n            config = plugin.getConfig();\n        } else\n            plugin.upgradeConfig();\n\n        // checkConfigVersion will call #reload() again anyways\n        if (checkConfigVersion()) return;\n\n        Messenger.reloadConfig(config.getBoolean(\"debug.enabled\"), config.getString(\"message-prefix\"));\n\n        WeaponDamages.initialise(plugin); //Reload weapon damages from config\n\n        reloadModesets();\n        reloadWorlds();\n\n        //Set EntityDamagedByEntityListener to enabled if either of these modules is enabled\n        final EntityDamageByEntityListener EDBEL = EntityDamageByEntityListener.getINSTANCE();\n        if (EDBEL != null) {\n            EDBEL.setEnabled(moduleEnabled(\"old-tool-damage\") ||\n                    moduleEnabled(\"old-potion-effects\")\n                    || moduleEnabled(\"old-critical-hits\")\n            );\n        }\n\n        // Dynamically registers / unregisters all event listeners for optimal performance!\n        ModuleLoader.toggleModules();\n\n        ModuleLoader.getModules().forEach(module -> {\n            try {\n                module.reload();\n            } catch (Exception e) {\n                plugin.getLogger()\n                        .log(Level.WARNING, \"Error reloading module '\" + module.toString() + \"'\", e);\n            }\n        });\n\n    }\n\n    private static void reloadModesets() {\n        modesets.clear();\n        alwaysEnabledModules.clear();\n        disabledModules.clear();\n\n        final Set<String> moduleNames = ModuleLoader.getModules().stream()\n                .map(OCMModule::getConfigName)\n                .map(Config::normaliseModuleName)\n                .collect(Collectors.toSet());\n        final Set<String> knownModuleNames = new HashSet<>(moduleNames);\n        knownModuleNames.addAll(optionalModules);\n        final Set<String> configurableModuleNames = new HashSet<>(knownModuleNames);\n        configurableModuleNames.removeAll(internalModules);\n        final ConfigurationSection modesetsSection = config.getConfigurationSection(\"modesets\");\n        if (modesetsSection == null) {\n            throw new IllegalStateException(\"Missing 'modesets' section in config. Every module must be assigned to a modeset, always_enabled_modules, or disabled_modules.\");\n        }\n\n        final List<String> errors = new ArrayList<>();\n\n        // A set to keep track of all the modules that are already in a modeset\n        final Set<String> modulesInModesets = new HashSet<>();\n\n        final List<String> alwaysModules = config.getStringList(\"always_enabled_modules\");\n        for (String moduleName : alwaysModules) {\n            final String normalised = normaliseModuleName(moduleName);\n            if (internalModules.contains(normalised)) {\n                errors.add(\"Internal module should not be configured: \" + moduleName);\n                continue;\n            }\n            if (!configurableModuleNames.contains(normalised)) {\n                Messenger.warn(\"Unknown module in always_enabled_modules: \" + moduleName);\n                continue;\n            }\n            alwaysEnabledModules.add(normalised);\n        }\n\n        final List<String> disabledModuleList = config.getStringList(\"disabled_modules\");\n        for (String moduleName : disabledModuleList) {\n            final String normalised = normaliseModuleName(moduleName);\n            if (internalModules.contains(normalised)) {\n                errors.add(\"Internal module should not be configured: \" + moduleName);\n                continue;\n            }\n            if (!configurableModuleNames.contains(normalised)) {\n                Messenger.warn(\"Unknown module in disabled_modules: \" + moduleName);\n                continue;\n            }\n            disabledModules.add(normalised);\n        }\n\n        for (String moduleName : alwaysEnabledModules) {\n            if (disabledModules.contains(moduleName)) {\n                errors.add(\"Module listed in both always_enabled_modules and disabled_modules: \" + moduleName);\n            }\n        }\n\n        // Iterate over each modeset\n        for (String key : modesetsSection.getKeys(false)) {\n            // Retrieve the list of module names for the current modeset\n            final List<String> moduleList = modesetsSection.getStringList(key);\n            final Set<String> moduleSet = new HashSet<>();\n            for (String moduleName : moduleList) {\n                final String normalised = normaliseModuleName(moduleName);\n                if (internalModules.contains(normalised)) {\n                    errors.add(\"Internal module should not be configured: \" + moduleName);\n                    continue;\n                }\n                if (!configurableModuleNames.contains(normalised)) {\n                    Messenger.warn(\"Unknown module in modeset '%s': %s\", key, moduleName);\n                    continue;\n                }\n                if (disabledModules.contains(normalised)) {\n                    errors.add(\"Module listed in disabled_modules and modeset '\" + key + \"': \" + moduleName);\n                    continue; // skip to next module name\n                }\n                if (alwaysEnabledModules.contains(normalised)) {\n                    errors.add(\"Module listed in always_enabled_modules and modeset '\" + key + \"': \" + moduleName);\n                    continue;\n                }\n                moduleSet.add(normalised);\n            }\n\n            // Add the current modeset and its modules to the map\n            modesets.put(key, moduleSet);\n\n            // Add all modules in the current modeset to the tracking set\n            modulesInModesets.addAll(moduleSet);\n        }\n\n        for (String moduleName : configurableModuleNames) {\n            if (disabledModules.contains(moduleName)) {\n                continue;\n            }\n            if (alwaysEnabledModules.contains(moduleName)) {\n                continue;\n            }\n            if (modulesInModesets.contains(moduleName)) {\n                continue;\n            }\n            errors.add(\"Module not assigned to any list: \" + moduleName);\n        }\n\n        if (!errors.isEmpty()) {\n            final String message = \"Invalid module assignment configuration:\\n - \" + String.join(\"\\n - \", errors)\n                    + \"\\nEvery module must appear in exactly one of always_enabled_modules, disabled_modules, or modesets.\";\n            throw new IllegalStateException(message);\n        }\n\n        alwaysEnabledModules.addAll(internalModules);\n    }\n\n    private static void reloadWorlds() {\n        worlds.clear();\n\n        final ConfigurationSection worldsSection = config.getConfigurationSection(\"worlds\");\n\n        // Iterate over each world\n        for (String worldName : worldsSection.getKeys(false)) {\n            final World world = Bukkit.getWorld(worldName);\n            if(world == null){\n                Messenger.warn(\"Configured world \" + worldName + \" not found, skipping (might be loaded later?)...\");\n                continue;\n            }\n            addWorld(world, worldsSection);\n        }\n    }\n\n    public static void addWorld(World world){\n        final ConfigurationSection worldsSection = config.getConfigurationSection(\"worlds\");\n        addWorld(world, worldsSection);\n    }\n\n    public static void addWorld(World world, ConfigurationSection worldsSection) {\n        // Retrieve the list of modeset names for the current world\n        // Using a linkedhashset to remove duplicates but retain insertion order (important for default modeset)\n        final LinkedHashSet<String> modesetsSet = new LinkedHashSet<>(worldsSection.getStringList(world.getName()));\n\n        // Add the current world and its modesets to the map\n        worlds.put(world.getUID(), modesetsSet);\n    }\n\n    public static void removeWorld(World world){\n        worlds.remove(world.getUID());\n    }\n\n    /**\n     * Get the default modeset for the given world.\n     * @param worldId The UUID for the world to check the allowed modesets for\n     * @return The default modeset, if found. Otherwise null.\n     */\n    public static @Nullable Set<String> getDefaultModeset(UUID worldId){\n        if(!worlds.containsKey(worldId)) return null;\n\n        final Set<String> set = worlds.get(worldId);\n        if(set == null || set.isEmpty()) return null;\n\n        final Iterator<String> iterator = set.iterator();\n        if(iterator.hasNext()) {\n            final String modesetName = iterator.next();\n            if(modesets.containsKey(modesetName)){\n                return modesets.get(modesetName);\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Checks whether the module is present in the default modeset for the specified world\n     * @param world The world to get the default modeset for\n     * @return Whether the module is enabled for the found modeset\n     */\n    public static boolean moduleEnabled(String moduleName, World world) {\n        final String normalised = normaliseModuleName(moduleName);\n        if (disabledModules.contains(normalised)) return false;\n        if (alwaysEnabledModules.contains(normalised)) return true;\n        if (world == null) return isModuleInAnyModeset(normalised); // Only checking if module is globally enabled\n\n        final Set<String> defaultModeset = getDefaultModeset(world.getUID());\n        // If no default modeset found, the module should be enabled\n        if(defaultModeset == null){\n            return isModuleInAnyModeset(normalised);\n        }\n\n        // Check if module is in default modeset\n        return defaultModeset.contains(normalised);\n    }\n\n    /**\n     * Check if module is globally enable under its own config section\n     * @param moduleName The name of the module to check\n     * @return Whether the module has enabled: true in its config section\n     */\n    public static boolean moduleEnabled(String moduleName) {\n        return moduleEnabled(moduleName, null);\n    }\n\n    public static boolean debugEnabled() {\n        return config.getBoolean(\"debug.enabled\");\n    }\n\n    public static boolean moduleSettingEnabled(String moduleName, String moduleSettingName) {\n        return config.getBoolean(moduleName + \".\" + moduleSettingName);\n    }\n\n    /**\n     * Only use if you can't access config through plugin instance\n     *\n     * @return config.yml instance\n     */\n    public static FileConfiguration getConfig() {\n        return plugin.getConfig();\n    }\n\n    public static Map<String, Set<String>> getModesets(){\n        return modesets;\n    }\n\n    public static boolean isModuleAlwaysEnabled(String moduleName) {\n        return alwaysEnabledModules.contains(normaliseModuleName(moduleName));\n    }\n\n    public static boolean isModuleDisabled(String moduleName) {\n        return disabledModules.contains(normaliseModuleName(moduleName));\n    }\n\n    public static boolean isModuleInAnyModeset(String moduleName) {\n        return modesets.values().stream().anyMatch(set -> set.contains(normaliseModuleName(moduleName)));\n    }\n\n    public static Map<UUID, Set<String>> getWorlds() {\n        return worlds;\n    }\n\n    private static String normaliseModuleName(String moduleName) {\n        return moduleName == null ? \"\" : moduleName.toLowerCase(Locale.ROOT);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/ConfigUtils.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\nimport com.cryptomorin.xseries.XMaterial;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionDurations;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionKey;\nimport org.bukkit.Material;\nimport org.bukkit.configuration.ConfigurationSection;\n\nimport java.util.*;\nimport java.util.function.Predicate;\nimport java.util.stream.Collectors;\n\n/**\n * Various utilities for making it easier to work with {@link org.bukkit.configuration.Configuration Configurations}.\n *\n * @see org.bukkit.configuration.file.YamlConfiguration\n * @see org.bukkit.configuration.ConfigurationSection\n */\npublic class ConfigUtils {\n    private static final Set<String> warnedUnknownPotionDurationKeys = Collections.synchronizedSet(new HashSet<>());\n    private static final Set<String> warnedUnknownMaterialListKeys = Collections.synchronizedSet(new HashSet<>());\n    private static final Set<String> warnedUnknownMaterialMapKeys = Collections.synchronizedSet(new HashSet<>());\n\n    /**\n     * Safely loads all doubles from a configuration section, reading both double and integer values.\n     *\n     * @param section The section from which to load the doubles.\n     * @return The map of doubles.\n     */\n    public static Map<String, Double> loadDoubleMap(ConfigurationSection section) {\n        Objects.requireNonNull(section, \"section cannot be null!\");\n\n        return section.getKeys(false).stream()\n                .filter(((Predicate<String>) section::isDouble).or(section::isInt))\n                .collect(Collectors.toMap(key -> key, section::getDouble));\n    }\n\n    /**\n     * Loads a material-to-double map from a configuration section.\n     * Safely ignores non-matching materials.\n     *\n     * @param section The section from which to load the values.\n     * @return The loaded material map.\n     */\n    public static Map<Material, Double> loadMaterialDoubleMap(ConfigurationSection section) {\n        Objects.requireNonNull(section, \"section cannot be null!\");\n\n        final String fullKey = section.getCurrentPath() == null ? \"\" : section.getCurrentPath();\n\n        return section.getKeys(false).stream()\n                .filter(((Predicate<String>) section::isDouble).or(section::isInt))\n                .map(key -> {\n                    Material material = matchMaterial(key);\n                    if (material == null && isUnknownMaterialKey(key)) {\n                        warnUnknownMaterialMap(fullKey, key);\n                        return null;\n                    }\n                    if (material == null) {\n                        return null;\n                    }\n                    return new AbstractMap.SimpleImmutableEntry<>(material, section.getDouble(key));\n                })\n                .filter(Objects::nonNull)\n                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (left, right) -> right));\n    }\n\n    /**\n     * Loads the list of {@link Material Materials} with the given key from a configuration section.\n     * Safely ignores non-matching materials.\n     *\n     * @param section The section from which to load the material list.\n     * @param key     The key of the material list.\n     * @return The loaded material list, or an empty list if there is no list at the given key.\n     */\n    public static List<Material> loadMaterialList(ConfigurationSection section, String key) {\n        Objects.requireNonNull(section, \"section cannot be null!\");\n        Objects.requireNonNull(key, \"key cannot be null!\");\n\n        if (!section.isList(key)) return new ArrayList<>();\n\n        final String basePath = section.getCurrentPath();\n        final String fullKey = basePath == null || basePath.isEmpty() ? key : basePath + \".\" + key;\n\n        return section.getStringList(key).stream()\n                .map(String::trim)\n                .map(name -> {\n                    Material material = matchMaterial(name);\n                    if (material == null && isUnknownMaterialKey(name)) {\n                        warnUnknownMaterial(fullKey, name);\n                        return null;\n                    }\n                    return material;\n                })\n                .filter(Objects::nonNull)\n                .collect(Collectors.toList());\n    }\n\n    private static void warnUnknownMaterial(String fullKey, String name) {\n        final String warnKey = fullKey + \":\" + name.toUpperCase(Locale.ROOT);\n        if (warnedUnknownMaterialListKeys.add(warnKey)) {\n            Messenger.warn(\"Unknown material '%s' in config list '%s'; skipping\", name, fullKey);\n        }\n    }\n\n    private static void warnUnknownMaterialMap(String fullKey, String name) {\n        final String warnKey = fullKey + \":\" + name.toUpperCase(Locale.ROOT);\n        if (warnedUnknownMaterialMapKeys.add(warnKey)) {\n            Messenger.warn(\"Unknown material '%s' in config section '%s'; skipping\", name, fullKey);\n        }\n    }\n\n    private static Material matchMaterial(String name) {\n        Optional<XMaterial> match = XMaterial.matchXMaterial(name);\n        if (match.isPresent()) {\n            return match.get().parseMaterial();\n        }\n\n        return Material.matchMaterial(name);\n    }\n\n    private static boolean isUnknownMaterialKey(String name) {\n        return !XMaterial.matchXMaterial(name).isPresent() && Material.matchMaterial(name) == null;\n    }\n\n    /**\n     * Gets potion duration values from config for all configured potion types.\n     * Will create map of potion keys to durations.\n     *\n     * @param section The section from which to load the duration values\n     * @return HashMap of {@link String} and {@link PotionDurations}\n     */\n    public static HashMap<PotionKey, PotionDurations> loadPotionDurationsList(ConfigurationSection section) {\n        Objects.requireNonNull(section, \"potion durations section cannot be null!\");\n\n        final HashMap<PotionKey, PotionDurations> durationsHashMap = new HashMap<>();\n        final ConfigurationSection durationsSection = section.getConfigurationSection(\"potion-durations\");\n\n        final ConfigurationSection drinkableSection = durationsSection.getConfigurationSection(\"drinkable\");\n        final ConfigurationSection splashSection = durationsSection.getConfigurationSection(\"splash\");\n\n        for (String newPotionTypeName : drinkableSection.getKeys(false)) {\n            // Get durations in seconds and convert to ticks\n            final int drinkableDuration = drinkableSection.getInt(newPotionTypeName) * 20;\n            final int splashDuration = splashSection.getInt(newPotionTypeName) * 20;\n\n            Optional<PotionKey> potionKey = PotionKey.fromConfigKey(newPotionTypeName);\n            if (potionKey.isPresent()) {\n                durationsHashMap.put(potionKey.get(), new PotionDurations(drinkableDuration, splashDuration));\n            } else if (warnedUnknownPotionDurationKeys.add(newPotionTypeName)) {\n                Messenger.warn(\"Unknown potion type '%s' in old-potion-effects.potion-durations; skipping\", newPotionTypeName);\n            }\n        }\n\n        return durationsHashMap;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/EventRegistry.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\nimport org.bukkit.event.HandlerList;\nimport org.bukkit.event.Listener;\nimport org.bukkit.plugin.Plugin;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * A simple utility class to ensure that a Listener is not registered more than once.\n */\npublic class EventRegistry {\n    private final Plugin plugin;\n    private final List<Listener> listeners = new ArrayList<>();\n\n    public EventRegistry(Plugin plugin) {\n        this.plugin = plugin;\n    }\n\n    /**\n     * Registers a listener and returns <code>true</code> if the listener was not already registered.\n     *\n     * @param listener The {@link Listener} to register.\n     * @return Whether the listener was successfully registered.\n     */\n    public boolean registerListener(Listener listener) {\n        if (listeners.contains(listener)) return false;\n\n        listeners.add(listener);\n        plugin.getServer().getPluginManager().registerEvents(listener, plugin);\n        return true;\n    }\n\n    /**\n     * Unregisters a listener and returns <code>true</code> if the listener was already registered.\n     *\n     * @param listener The {@link Listener} to register.\n     * @return Whether the listener was successfully unregistered.\n     */\n    public boolean unregisterListener(Listener listener) {\n        if (!listeners.contains(listener)) return false;\n\n        listeners.remove(listener);\n        HandlerList.unregisterAll(listener);\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/MathsHelper.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\n/**\n * For all the maths utilities that I needed which (for some reason) aren't in the Math class.\n */\npublic class MathsHelper {\n\n    /**\n     * Clamps a value between a minimum and a maximum.\n     *\n     * @param value The value to clamp.\n     * @param min   The minimum value to clamp to.\n     * @param max   The maximum value to clamp to.\n     * @return The clamped value.\n     */\n    public static double clamp(double value, double min, double max) {\n        return Math.max(Math.min(value, max), min);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/Messenger.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport org.bukkit.ChatColor;\nimport org.bukkit.command.CommandSender;\n\nimport java.util.Objects;\nimport java.util.logging.Level;\n\npublic class Messenger {\n\n    public static final String HORIZONTAL_BAR = ChatColor.STRIKETHROUGH + \"----------------------------------------------------\";\n    private static OCMMain plugin;\n\n    private static boolean DEBUG_ENABLED = false;\n    private static String PREFIX = \"&6[OCM]&r\";\n\n    public static void initialise(OCMMain plugin) {\n        Messenger.plugin = plugin;\n    }\n\n    public static void reloadConfig(boolean debugEnabled, String prefix){\n        DEBUG_ENABLED = debugEnabled;\n        PREFIX = prefix;\n    }\n\n    public static void info(String message, Object... args) {\n        plugin.getLogger().info(TextUtils.stripColour(String.format(message, args)));\n    }\n\n    public static void warn(Throwable e, String message, Object... args) {\n        plugin.getLogger().log(Level.WARNING, TextUtils.stripColour(String.format(message, args)), e);\n    }\n\n    public static void warn(String message, Object... args) {\n        plugin.getLogger().log(Level.WARNING, TextUtils.stripColour(String.format(message, args)));\n    }\n\n    /**\n     * This will format any ampersand (&) color codes,\n     * format any args passed to it using {@link String#format(String, Object...)},\n     * and then send the message to the specified {@link CommandSender}.\n     *\n     * @param sender  The {@link CommandSender} to send the message to.\n     * @param message The message to send.\n     * @param args    The args to format the message with.\n     */\n    public static void sendNoPrefix(CommandSender sender, String message, Object... args) {\n        Objects.requireNonNull(sender, \"sender cannot be null!\");\n        Objects.requireNonNull(message, \"message cannot be null!\");\n        // Prevents sending of individual empty messages, allowing for selective message disabling.\n        if (message.isEmpty()) return;\n        sender.sendMessage(TextUtils.colourise(String.format(message, args)));\n    }\n\n    /**\n     * This will add the prefix to the message, format any ampersand (&) color codes,\n     * format any args passed to it using {@link String#format(String, Object...)},\n     * and then send the message to the specified {@link CommandSender}.\n     *\n     * @param sender  The {@link CommandSender} to send the message to.\n     * @param message The message to send.\n     * @param prefix  The prefix to the message\n     * @param args    The args to format the message with.\n     */\n    private static void sendWithPrefix(CommandSender sender, String message, String prefix, Object... args) {\n        // Prevents sending of individual empty messages, allowing for selective message disabling.\n        if (message.isEmpty()) return;\n        sendNoPrefix(sender, prefix + \" \" + message, args);\n    }\n\n    public static void send(CommandSender sender, String message, Object... args) {\n        sendWithPrefix(sender, message, PREFIX, args);\n    }\n\n    private static void sendDebugMessage(CommandSender sender, String message, Object... args) {\n        sendWithPrefix(sender, message, \"&1[Debug]&r\", args);\n    }\n\n    public static void debug(String message, Throwable throwable) {\n        if (DEBUG_ENABLED) plugin.getLogger().log(Level.INFO, message, throwable);\n    }\n\n    public static void debug(String message, Object... args) {\n        if (DEBUG_ENABLED) info(\"[DEBUG] \" + message, args);\n    }\n\n    public static void debug(CommandSender sender, String message, Object... args) {\n        if (DEBUG_ENABLED) sendDebugMessage(sender, message, args);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/TextUtils.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities;\n\nimport org.bukkit.ChatColor;\n\npublic class TextUtils {\n    /**\n     * Converts ampersand (&) color codes to Minecraft ({@link ChatColor#COLOR_CHAR}) color codes.\n     *\n     * @param text The text to colourise.\n     * @return The colourised text.\n     */\n    public static String colourise(String text) {\n        return ChatColor.translateAlternateColorCodes('&', text);\n    }\n\n    /**\n     * Removes all Minecraft ({@link ChatColor#COLOR_CHAR}) colour codes from a string.\n     *\n     * @param text The text to strip colours from.\n     * @return The stripped text.\n     */\n    public static String stripColour(String text) {\n        return ChatColor.stripColor(text);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/AttackCooldownTracker.java",
    "content": "package kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.VersionCompatUtils;\nimport org.bukkit.Bukkit;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.player.PlayerQuitEvent;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.UUID;\n\n/**\n * Spigot versions below 1.16 did not have way of getting attack cooldown.\n * Obtaining through NMS works, but value is reset before EntityDamageEvent is called.\n * This means we must keep track of the cooldown to get the correct values.\n */\npublic class AttackCooldownTracker extends OCMModule {\n    private static AttackCooldownTracker INSTANCE;\n    private final Map<UUID, Float> lastCooldown;\n\n    public AttackCooldownTracker(OCMMain plugin) {\n        super(plugin, \"attack-cooldown-tracker\");\n        lastCooldown = new HashMap<>();\n\n        // This module only matters on versions where HumanEntity#getAttackCooldown does not exist (pre-1.16).\n        // OCMMain already gates registration via feature detection, but keep this as a safety net in case a\n        // fork/backport adds the method or another plugin initialises this module manually.\n        if (Reflector.getMethod(HumanEntity.class, \"getAttackCooldown\", 0) != null) {\n            INSTANCE = null;\n            return;\n        }\n\n        INSTANCE = this;\n\n        Runnable cooldownTask = () -> Bukkit.getOnlinePlayers().forEach(\n                player -> lastCooldown.put(player.getUniqueId(),\n                        VersionCompatUtils.getAttackCooldown(player)\n                ));\n        // Performance: one global per-tick task, not per-player. We must sample every tick because the NMS value\n        // is reset before the Bukkit damage event fires, so on-demand reads would be incorrect.\n        Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, cooldownTask, 0, 1L);\n    }\n\n    @EventHandler\n    public void onPlayerQuit(PlayerQuitEvent event){\n        lastCooldown.remove(event.getPlayer().getUniqueId());\n    }\n\n    public static Float getLastCooldown(UUID uuid) {\n        final AttackCooldownTracker instance = INSTANCE;\n        if (instance == null) return null;\n        return instance.lastCooldown.get(uuid);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/DamageUtils.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser;\nimport org.bukkit.Material;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.entity.Player;\nimport org.bukkit.potion.PotionEffect;\nimport org.bukkit.potion.PotionEffectType;\n\npublic class DamageUtils {\n\n    // Method added in 1.16.4\n    private static final SpigotFunctionChooser<LivingEntity, Object, Boolean> isInWater = SpigotFunctionChooser.apiCompatCall(\n            (le, params) -> le.isInWater(),\n            (le, params) -> le.getLocation().getBlock().getType() == Material.WATER\n    );\n\n    // Method added in 1.17.0\n    private static final SpigotFunctionChooser<LivingEntity, Object, Boolean> isClimbing = SpigotFunctionChooser.apiCompatCall(\n            (le, params) -> le.isClimbing(),\n            (le, params) -> {\n                final Material material = le.getLocation().getBlock().getType();\n                return material == Material.LADDER || material == Material.VINE;\n            }\n    );\n\n    // Method added in 1.16. Default parameter for reflected method is 0.5F\n    public static final SpigotFunctionChooser<HumanEntity, Object, Float> getAttackCooldown =\n            SpigotFunctionChooser.apiCompatCall(\n                    (he, params) -> he.getAttackCooldown(),\n                    (he, params) -> getAttackCooldown(he)\n            );\n\n    /**\n     * Gets last stored attack cooldown. Used when method is not available and we are keeping track of value ourselves.\n     * @param humanEntity The player to get the attack cooldown from\n     * @return The attack cooldown, as a value between 0 and 1\n     */\n    private static float getAttackCooldown(HumanEntity humanEntity){\n        final Float cooldown = AttackCooldownTracker.getLastCooldown(humanEntity.getUniqueId());\n        if(cooldown == null){\n            Messenger.debug(\"Last attack cooldown null for \" + humanEntity.getName() + \", assuming full attack strength\");\n            return 1L;\n        }\n        return cooldown;\n    }\n\n    /**\n     * Get sharpness damage multiplier for 1.9\n     *\n     * @param level The level of the enchantment\n     * @return Sharpness damage multiplier\n     */\n    public static double getNewSharpnessDamage(int level) {\n        return level >= 1 ? 1 + (level - 1) * 0.5 : 0;\n    }\n\n    /**\n     * Get sharpness damage multiplier for 1.8\n     *\n     * @param level The level of the enchantment\n     * @return Sharpness damage multiplier\n     */\n    public static double getOldSharpnessDamage(int level) {\n        return level >= 1 ? level * 1.25 : 0;\n    }\n\n    /**\n     * Check preconditions for critical hit\n     *\n     * @param humanEntity Living entity to perform checks on\n     * @return Whether hit is critical\n     */\n    public static boolean isCriticalHit1_8(HumanEntity humanEntity) {\n        /* Code from Bukkit 1.8_R3:\n        boolean flag = this.fallDistance > 0.0F && !this.onGround && !this.onClimbable() && !this.isInWater()\n        && !this.hasEffect(MobEffectList.BLINDNESS) && this.vehicle == null && entity instanceof EntityLiving;\n        */\n        return humanEntity.getFallDistance() > 0.0F &&\n                !humanEntity.isOnGround() &&\n                !isClimbing.apply(humanEntity) &&\n                !isInWater.apply(humanEntity) &&\n                humanEntity.getActivePotionEffects().stream().map(PotionEffect::getType)\n                        .noneMatch(e -> e == PotionEffectType.BLINDNESS) &&\n                !humanEntity.isInsideVehicle();\n    }\n\n    public static boolean isCriticalHit1_9(Player player) {\n        return isCriticalHit1_8(player) && getAttackCooldown.apply(player) > 0.9F && !player.isSprinting();\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/DefenceUtils.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport com.cryptomorin.xseries.XAttribute;\nimport com.cryptomorin.xseries.XEnchantment;\nimport com.cryptomorin.xseries.XPotion;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser;\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.VersionCompatUtils;\nimport org.bukkit.Material;\nimport org.bukkit.NamespacedKey;\nimport org.bukkit.attribute.AttributeModifier;\nimport org.bukkit.enchantments.Enchantment;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport org.bukkit.inventory.EquipmentSlot;\nimport org.bukkit.inventory.ItemStack;\n\nimport java.util.Collection;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Supplier;\n\nimport static kernitus.plugin.OldCombatMechanics.utilities.Messenger.debug;\n\n/**\n * Utilities for calculating damage reduction from armour and status effects.\n * Defence order is armour defence -> resistance -> armour enchants ->\n * absorption\n * BASE -> HARD_HAT -> BLOCKING -> ARMOUR -> RESISTANCE -> MAGIC -> ABSORPTION\n * This class just deals with everything from armour onwards\n */\npublic class DefenceUtils {\n    private static final double REDUCTION_PER_ARMOUR_POINT = 0.04;\n    private static final double REDUCTION_PER_RESISTANCE_LEVEL = 0.2;\n\n    private static final Set<EntityDamageEvent.DamageCause> ARMOUR_IGNORING_CAUSES = EnumSet.of(\n            EntityDamageEvent.DamageCause.FIRE_TICK,\n            EntityDamageEvent.DamageCause.SUFFOCATION,\n            EntityDamageEvent.DamageCause.DROWNING,\n            EntityDamageEvent.DamageCause.STARVATION,\n            EntityDamageEvent.DamageCause.FALL,\n            EntityDamageEvent.DamageCause.VOID,\n            EntityDamageEvent.DamageCause.CUSTOM,\n            EntityDamageEvent.DamageCause.MAGIC,\n            EntityDamageEvent.DamageCause.WITHER,\n            // From 1.9\n            EntityDamageEvent.DamageCause.FLY_INTO_WALL,\n            EntityDamageEvent.DamageCause.DRAGON_BREATH\n    // In 1.19 FIRE bypasses armour, but it doesn't in 1.8 so we don't add it here\n    );\n    private static final Set<String> warnedUnknownArmourEnchants = new HashSet<>();\n    private static final Set<Enchantment> VANILLA_ENCHANTMENTS = initVanillaEnchantments();\n\n    // Stalagmite ignores armour but other blocks under CONTACT do not, explicitly\n    // checked below\n    static {\n        if (Reflector.versionIsNewerOrEqualTo(1, 11, 0))\n            ARMOUR_IGNORING_CAUSES.add(EntityDamageEvent.DamageCause.CRAMMING);\n        if (Reflector.versionIsNewerOrEqualTo(1, 17, 0))\n            ARMOUR_IGNORING_CAUSES.add(EntityDamageEvent.DamageCause.FREEZE);\n    }\n\n    // Method added in 1.15\n    private static final SpigotFunctionChooser<LivingEntity, Object, Double> getAbsorptionAmount = SpigotFunctionChooser\n            .apiCompatCall(\n                    (le, params) -> le.getAbsorptionAmount(),\n                    (le, params) -> Double.valueOf(VersionCompatUtils.getAbsorptionAmount(le)));\n\n    /**\n     * Calculates the reduction by armour, resistance, armour enchantments and\n     * absorption.\n     * The values are updated directly in the map for each damage modifier.\n     *\n     * @param damagedEntity   The entity that was damaged\n     * @param damageModifiers A map of the damage modifiers and their values from\n     *                        the event\n     * @param damageCause     The cause of the damage\n     */\n    @SuppressWarnings(\"deprecation\")\n    public static void calculateDefenceDamageReduction(LivingEntity damagedEntity,\n            Map<EntityDamageEvent.DamageModifier, Double> damageModifiers,\n            EntityDamageEvent.DamageCause damageCause,\n            boolean randomness) {\n\n        final double armourPoints = damagedEntity.getAttribute(XAttribute.ARMOR.get()).getValue();\n        // Make sure we don't go over 100% protection\n        final double armourReductionFactor = Math.min(1.0, armourPoints * REDUCTION_PER_ARMOUR_POINT);\n\n        // applyArmorModifier() calculations from NMS\n        // Apply armour damage reduction after hard hat (wearing helmet & hit by block)\n        // and blocking reduction\n        double currentDamage = damageModifiers.get(EntityDamageEvent.DamageModifier.BASE) +\n                damageModifiers.getOrDefault(EntityDamageEvent.DamageModifier.HARD_HAT, 0.0) +\n                damageModifiers.getOrDefault(EntityDamageEvent.DamageModifier.BLOCKING, 0.0);\n        if (damageModifiers.containsKey(EntityDamageEvent.DamageModifier.ARMOR)) {\n            double armourReduction = 0;\n            // If the damage cause does not ignore armour\n            // If the block they are in is a stalagmite, also ignore armour\n            if (!ARMOUR_IGNORING_CAUSES.contains(damageCause) &&\n                    !(Reflector.versionIsNewerOrEqualTo(1, 19, 0) &&\n                            damageCause == EntityDamageEvent.DamageCause.CONTACT &&\n                            damagedEntity.getLocation().getBlock().getType() == Material.POINTED_DRIPSTONE)) {\n                armourReduction = currentDamage * -armourReductionFactor;\n            }\n            damageModifiers.put(EntityDamageEvent.DamageModifier.ARMOR, armourReduction);\n            currentDamage += armourReduction;\n        }\n\n        // This is the applyMagicModifier() calculations from NMS\n        if (damageCause != EntityDamageEvent.DamageCause.STARVATION) {\n            // Apply resistance effect\n            if (damageModifiers.containsKey(EntityDamageEvent.DamageModifier.RESISTANCE) &&\n                    damageCause != EntityDamageEvent.DamageCause.VOID &&\n                    damagedEntity.hasPotionEffect(XPotion.RESISTANCE.get())) {\n                final int level = damagedEntity.getPotionEffect(XPotion.RESISTANCE.get()).getAmplifier()\n                        + 1;\n                // Make sure we don't go over 100% protection\n                final double resistanceReductionFactor = Math.min(1.0, level * REDUCTION_PER_RESISTANCE_LEVEL);\n                final double resistanceReduction = -resistanceReductionFactor * currentDamage;\n                damageModifiers.put(EntityDamageEvent.DamageModifier.RESISTANCE, resistanceReduction);\n                currentDamage += resistanceReduction;\n            }\n\n            // Apply armour enchants\n            // Don't calculate enchants if damage already 0 (like 1.8 NMS). Enchants cap at\n            // 80% reduction\n            if (currentDamage > 0 && damageModifiers.containsKey(EntityDamageEvent.DamageModifier.MAGIC)) {\n                final double enchantsReductionFactor = calculateArmourEnchantmentReductionFactor(\n                        damagedEntity.getEquipment().getArmorContents(), damageCause, randomness);\n                final double enchantsReduction = currentDamage * -enchantsReductionFactor;\n                damageModifiers.put(EntityDamageEvent.DamageModifier.MAGIC, enchantsReduction);\n                currentDamage += enchantsReduction;\n            }\n\n            // Absorption\n            if (damageModifiers.containsKey(EntityDamageEvent.DamageModifier.ABSORPTION)) {\n                final double absorptionAmount = getAbsorptionAmount.apply(damagedEntity);\n                double absorptionReduction = -Math.min(absorptionAmount, currentDamage);\n                damageModifiers.put(EntityDamageEvent.DamageModifier.ABSORPTION, absorptionReduction);\n            }\n        }\n    }\n\n    /**\n     * Return the damage after applying armour, resistance, and armour enchants\n     * protections, following 1.8 algorithm.\n     *\n     * @param defender       The entity that is being attacked\n     * @param baseDamage     The base damage done by the event, including weapon\n     *                       enchants, potions, crits\n     * @param armourContents The 4 pieces of armour contained in the armour slots\n     * @param damageCause    The source of damage\n     * @param randomness     Whether to apply random multiplier\n     * @return The damage done to the entity after armour is taken into account\n     */\n    public static double getDamageAfterArmour1_8(LivingEntity defender, double baseDamage, ItemStack[] armourContents,\n            EntityDamageEvent.DamageCause damageCause, boolean randomness) {\n        double armourPoints = 0;\n        for (int i = 0; i < armourContents.length; i++) {\n            final ItemStack itemStack = armourContents[i];\n            if (itemStack == null)\n                continue;\n            final EquipmentSlot slot = new EquipmentSlot[] { EquipmentSlot.FEET, EquipmentSlot.LEGS,\n                    EquipmentSlot.CHEST, EquipmentSlot.HEAD }[i];\n            armourPoints += getAttributeModifierSum(itemStack.getType().getDefaultAttributeModifiers(slot)\n                    .get(XAttribute.ARMOR.get()));\n        }\n\n        final double reductionFactor = armourPoints * REDUCTION_PER_ARMOUR_POINT;\n\n        // Apply armour damage reduction\n        double finalDamage = baseDamage\n                - (ARMOUR_IGNORING_CAUSES.contains(damageCause) ? 0 : (baseDamage * reductionFactor));\n\n        // Calculate resistance\n        if (defender.hasPotionEffect(XPotion.RESISTANCE.get())) {\n            int resistanceLevel = defender.getPotionEffect(XPotion.RESISTANCE.get()).getAmplifier() + 1;\n            finalDamage *= 1.0 - (resistanceLevel * 0.2);\n        }\n\n        // Don't calculate enchantment reduction if damage is already 0. NMS 1.8 does it\n        // this way.\n        final double enchantmentReductionFactor = calculateArmourEnchantmentReductionFactor(armourContents, damageCause,\n                randomness);\n        if (finalDamage > 0) {\n            finalDamage -= finalDamage * enchantmentReductionFactor;\n        }\n\n        debug(\"Reductions: Armour %.0f%%, Ench %.0f%%, Total %.2f%%, Start dmg: %.2f Final: %.2f\",\n                reductionFactor * 100,\n                enchantmentReductionFactor * 100,\n                (reductionFactor + (1 - reductionFactor) * enchantmentReductionFactor) * 100,\n                baseDamage, finalDamage);\n\n        return finalDamage;\n    }\n\n    /**\n     * Applies all the operations for the attribute modifiers of a specific\n     * attribute.\n     * Does not take into account the base value.\n     */\n    private static double getAttributeModifierSum(Collection<AttributeModifier> modifiers) {\n        double sum = 0;\n        for (AttributeModifier modifier : modifiers) {\n            final double value = modifier.getAmount();\n            switch (modifier.getOperation()) {\n                case ADD_SCALAR:\n                    sum += Math.abs(value);\n                    break;\n                case ADD_NUMBER:\n                    sum += value;\n                    break;\n                case MULTIPLY_SCALAR_1:\n                    sum *= value;\n                    break;\n            }\n        }\n        return sum;\n    }\n\n    private static double calculateArmourEnchantmentReductionFactor(ItemStack[] armourContents,\n            EntityDamageEvent.DamageCause cause, boolean randomness) {\n        int totalEpf = 0;\n        for (ItemStack armourItem : armourContents) {\n            if (armourItem != null && armourItem.getType() != Material.AIR) {\n                warnOnUnknownArmourEnchantments(armourItem);\n                for (EnchantmentType enchantmentType : EnchantmentType.values()) {\n                    if (!enchantmentType.protectsAgainst(cause))\n                        continue;\n\n                    int enchantmentLevel = armourItem.getEnchantmentLevel(enchantmentType.getEnchantment());\n\n                    if (enchantmentLevel > 0) {\n                        totalEpf += enchantmentType.getEpf(enchantmentLevel);\n                    }\n                }\n            }\n        }\n\n        // Cap at 25\n        totalEpf = Math.min(25, totalEpf);\n\n        // Multiply by random value between 50% and 100%, then round up\n        double multiplier = randomness ? ThreadLocalRandom.current().nextDouble(0.5, 1) : 1.0;\n        totalEpf = (int) Math.ceil(totalEpf * multiplier);\n\n        // Cap at 20\n        totalEpf = Math.min(20, totalEpf);\n\n        return REDUCTION_PER_ARMOUR_POINT * totalEpf;\n    }\n\n    private static void warnOnUnknownArmourEnchantments(ItemStack armourItem) {\n        if (armourItem.getEnchantments().isEmpty()) {\n            return;\n        }\n        for (Enchantment enchantment : armourItem.getEnchantments().keySet()) {\n            if (enchantment == null || isModelledArmourEnchantment(enchantment)) {\n                continue;\n            }\n            if (!shouldWarnOnUnknownEnchantment(enchantment)) {\n                continue;\n            }\n            final String name = enchantment.getName();\n            if (warnedUnknownArmourEnchants.add(name)) {\n                kernitus.plugin.OldCombatMechanics.utilities.Messenger.warn(\n                        \"Armour enchantment '%s' is not modelled by OCM damage reduction; results may differ\",\n                        name);\n            }\n        }\n    }\n\n    private static boolean shouldWarnOnUnknownEnchantment(Enchantment enchantment) {\n        try {\n            final NamespacedKey key = enchantment.getKey();\n            if (key != null) {\n                return !\"minecraft\".equals(key.getNamespace());\n            }\n        } catch (NoSuchMethodError | NoClassDefFoundError ignored) {\n            // Legacy servers do not expose NamespacedKey; fall back to known vanilla list.\n        }\n        return !isVanillaEnchantment(enchantment);\n    }\n\n    private static Set<Enchantment> initVanillaEnchantments() {\n        final Set<Enchantment> enchantments = new HashSet<>();\n        for (XEnchantment xEnchantment : XEnchantment.values()) {\n            final Enchantment enchantment = xEnchantment.getEnchant();\n            if (enchantment != null) {\n                enchantments.add(enchantment);\n            }\n        }\n        return enchantments;\n    }\n\n    private static boolean isVanillaEnchantment(Enchantment enchantment) {\n        return VANILLA_ENCHANTMENTS.contains(enchantment);\n    }\n\n    private static boolean isModelledArmourEnchantment(Enchantment enchantment) {\n        for (EnchantmentType enchantmentType : EnchantmentType.values()) {\n            if (enchantmentType.getEnchantment().equals(enchantment)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private enum EnchantmentType {\n        // Data from https://minecraft.fandom.com/wiki/Armor#Mechanics\n        PROTECTION(() -> {\n            EnumSet<EntityDamageEvent.DamageCause> damageCauses = EnumSet.of(\n                    EntityDamageEvent.DamageCause.CONTACT,\n                    EntityDamageEvent.DamageCause.ENTITY_ATTACK,\n                    EntityDamageEvent.DamageCause.PROJECTILE,\n                    EntityDamageEvent.DamageCause.FALL,\n                    EntityDamageEvent.DamageCause.FIRE,\n                    EntityDamageEvent.DamageCause.LAVA,\n                    EntityDamageEvent.DamageCause.BLOCK_EXPLOSION,\n                    EntityDamageEvent.DamageCause.ENTITY_EXPLOSION,\n                    EntityDamageEvent.DamageCause.LIGHTNING,\n                    EntityDamageEvent.DamageCause.POISON,\n                    EntityDamageEvent.DamageCause.MAGIC,\n                    EntityDamageEvent.DamageCause.WITHER,\n                    EntityDamageEvent.DamageCause.FALLING_BLOCK,\n                    EntityDamageEvent.DamageCause.THORNS,\n                    EntityDamageEvent.DamageCause.DRAGON_BREATH);\n            if (Reflector.versionIsNewerOrEqualTo(1, 10, 0))\n                damageCauses.add(EntityDamageEvent.DamageCause.HOT_FLOOR);\n            if (Reflector.versionIsNewerOrEqualTo(1, 12, 0))\n                damageCauses.add(EntityDamageEvent.DamageCause.ENTITY_SWEEP_ATTACK);\n\n            return damageCauses;\n        },\n                0.75, XEnchantment.PROTECTION.getEnchant()),\n        FIRE_PROTECTION(() -> {\n            EnumSet<EntityDamageEvent.DamageCause> damageCauses = EnumSet.of(\n                    EntityDamageEvent.DamageCause.FIRE,\n                    EntityDamageEvent.DamageCause.FIRE_TICK,\n                    EntityDamageEvent.DamageCause.LAVA);\n\n            if (Reflector.versionIsNewerOrEqualTo(1, 10, 0)) {\n                damageCauses.add(EntityDamageEvent.DamageCause.HOT_FLOOR);\n            }\n\n            return damageCauses;\n        }, 1.25, XEnchantment.FIRE_PROTECTION.getEnchant()),\n        BLAST_PROTECTION(() -> EnumSet.of(\n                EntityDamageEvent.DamageCause.ENTITY_EXPLOSION,\n                EntityDamageEvent.DamageCause.BLOCK_EXPLOSION), 1.5, XEnchantment.BLAST_PROTECTION.getEnchant()),\n        PROJECTILE_PROTECTION(() -> EnumSet.of(\n                EntityDamageEvent.DamageCause.PROJECTILE), 1.5, XEnchantment.PROJECTILE_PROTECTION.getEnchant()),\n        FALL_PROTECTION(() -> EnumSet.of(\n                EntityDamageEvent.DamageCause.FALL), 2.5, XEnchantment.FEATHER_FALLING.getEnchant());\n\n        private final Set<EntityDamageEvent.DamageCause> protection;\n        private final double typeModifier;\n        private final Enchantment enchantment;\n\n        EnchantmentType(Supplier<Set<EntityDamageEvent.DamageCause>> protection, double typeModifier,\n                Enchantment enchantment) {\n            this.protection = protection.get();\n            this.typeModifier = typeModifier;\n            this.enchantment = enchantment;\n        }\n\n        /**\n         * Returns whether the armour protects against the given damage cause.\n         *\n         * @param cause the damage cause\n         * @return true if the armour protects against the given damage cause\n         */\n        public boolean protectsAgainst(EntityDamageEvent.DamageCause cause) {\n            return protection.contains(cause);\n        }\n\n        /**\n         * Returns the bukkit enchantment.\n         *\n         * @return the bukkit enchantment\n         */\n        public Enchantment getEnchantment() {\n            return enchantment;\n        }\n\n        /**\n         * Returns the enchantment protection factor (EPF).\n         *\n         * @param level the level of the enchantment\n         * @return the EPF\n         */\n        public int getEpf(int level) {\n            // floor ( (6 + level^2) * TypeModifier / 3 )\n            return (int) Math.floor((6 + level * level) * typeModifier / 3);\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/EntityDamageByEntityListener.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking;\nimport org.bukkit.Bukkit;\nimport org.bukkit.entity.Entity;\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.entity.EntityDamageByEntityEvent;\nimport org.bukkit.event.entity.EntityDamageEvent;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.HashMap;\nimport java.util.Iterator;\n\npublic class EntityDamageByEntityListener extends OCMModule {\n\n    private static EntityDamageByEntityListener INSTANCE;\n    private boolean enabled;\n    private final Map<UUID, Double> lastDamages;\n    private final Map<UUID, Long> lastDamageExpiryTicks;\n    private long tickCounter;\n    private int expirySweepTaskId = -1;\n    private static final long EXPIRY_SWEEP_INTERVAL_TICKS = 20L;\n    private static final long MIN_LAST_DAMAGE_TTL_TICKS = 20L;\n\n    public EntityDamageByEntityListener(OCMMain plugin) {\n        super(plugin, \"entity-damage-listener\");\n        INSTANCE = this;\n        lastDamages = new HashMap<>();\n        lastDamageExpiryTicks = new HashMap<>();\n    }\n\n    public static EntityDamageByEntityListener getINSTANCE() {\n        return INSTANCE;\n    }\n\n    @Override\n    public boolean isEnabled() {\n        return enabled;\n    }\n\n    public void setEnabled(boolean enabled) {\n        this.enabled = enabled;\n        if (enabled) {\n            startExpirySweeperIfNeeded();\n        } else {\n            stopExpirySweeperIfNeeded();\n            lastDamages.clear();\n            lastDamageExpiryTicks.clear();\n        }\n    }\n\n    private void startExpirySweeperIfNeeded() {\n        if (expirySweepTaskId != -1) return;\n        expirySweepTaskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, () -> {\n            tickCounter++;\n            if (tickCounter % EXPIRY_SWEEP_INTERVAL_TICKS != 0) return;\n            sweepExpiredEntries();\n        }, 1L, 1L);\n    }\n\n    private void stopExpirySweeperIfNeeded() {\n        if (expirySweepTaskId == -1) return;\n        Bukkit.getScheduler().cancelTask(expirySweepTaskId);\n        expirySweepTaskId = -1;\n    }\n\n    private void touchExpiry(LivingEntity damagee) {\n        final UUID uuid = damagee.getUniqueId();\n        // Some implementations / test setups set maximumNoDamageTicks to 0, but damage immunity bookkeeping can\n        // still matter for a short period (e.g. cancelled fire ticks during invulnerability). Keep a small minimum.\n        final long delayTicks = Math.max(MIN_LAST_DAMAGE_TTL_TICKS, damagee.getMaximumNoDamageTicks());\n        final long candidateExpiry = tickCounter + delayTicks;\n        final Long existingExpiry = lastDamageExpiryTicks.get(uuid);\n        if (existingExpiry == null || candidateExpiry > existingExpiry) {\n            lastDamageExpiryTicks.put(uuid, candidateExpiry);\n        }\n    }\n\n    private void sweepExpiredEntries() {\n        final Iterator<Map.Entry<UUID, Long>> it = lastDamageExpiryTicks.entrySet().iterator();\n        while (it.hasNext()) {\n            final Map.Entry<UUID, Long> entry = it.next();\n            if (entry.getValue() > tickCounter) continue;\n            final UUID uuid = entry.getKey();\n            it.remove();\n            lastDamages.remove(uuid);\n        }\n    }\n\n    @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)\n    public void onEntityDamage(EntityDamageEvent event) {\n        final Entity damagee = event.getEntity();\n\n        if (!(event instanceof EntityDamageByEntityEvent)) {\n            // Damage immunity only applies to living entities\n            if (!(damagee instanceof LivingEntity)) return;\n            final LivingEntity livingDamagee = ((LivingEntity) damagee);\n\n            final Double storedDamage = lastDamages.get(livingDamagee.getUniqueId());\n            debug(\"Non-entity damage before restore: lastDamage=\" + livingDamagee.getLastDamage()\n                    + \" stored=\" + storedDamage, livingDamagee);\n            debug(\"Non-entity damage before restore: lastDamage=\" + livingDamagee.getLastDamage()\n                    + \" stored=\" + storedDamage);\n\n            restoreLastDamage(livingDamagee);\n            debug(\"Non-entity damage after restore: lastDamage=\" + livingDamagee.getLastDamage(), livingDamagee);\n            debug(\"Non-entity damage after restore: lastDamage=\" + livingDamagee.getLastDamage());\n\n            double newDamage = event.getDamage(); // base damage, before defence calculations\n\n            // Overdamage due to immunity\n            // Invulnerability will cause less damage if they attack with a stronger weapon while vulnerable\n            // That is, the difference in damage will be dealt, but only if new attack is stronger than previous one\n            newDamage = checkOverdamage(livingDamagee, event, newDamage);\n\n            if (newDamage < 0) {\n                debug(\"Damage was \" + newDamage + \" setting to 0\");\n                newDamage = 0;\n            }\n\n            // Set damage, this should scale effects in the 1.9 way in case some of our modules are disabled\n            event.setDamage(newDamage);\n            debug(\"Attack damage (before defence): \" + newDamage);\n\n        } else {\n            final Entity damager = ((EntityDamageByEntityEvent) event).getDamager();\n\n            // Call event constructor before setting lastDamage back, because we need it for calculations\n            final OCMEntityDamageByEntityEvent e = new OCMEntityDamageByEntityEvent\n                    (damager, damagee, event.getCause(), event.getDamage());\n\n            // Set last damage to actual value for other modules and plugins to use\n            // This will be set back to 0 in MONITOR listener on the next tick to detect all potential overdamages.\n            // If there is large delay between last time an entity was damaged and the next damage,\n            // the last damage might have been removed from the weak hash map. This is intended, as the immunity\n            // ticks tends to be a short period of time anyway and last damage is irrelevant after immunity has expired.\n            if (damagee instanceof LivingEntity)\n                restoreLastDamage((LivingEntity) damagee);\n\n            // Call event for the other modules to make their modifications\n            plugin.getServer().getPluginManager().callEvent(e);\n\n            if (e.isCancelled()) return;\n\n            // Now we re-calculate damage modified by the modules and set it back to original event\n            // Attack components order: (Base + Potion effects, scaled by attack delay) + Critical Hit + (Enchantments, scaled by attack delay)\n            // Hurt components order: Overdamage - Armour - Resistance - Armour enchants - Absorption\n            double newDamage = e.getBaseDamage();\n\n            debug(\"Base: \" + e.getBaseDamage(), damager);\n            debug(\"Base: \" + e.getBaseDamage());\n\n            // Weakness potion\n            final double weaknessModifier = e.getWeaknessModifier() * e.getWeaknessLevel();\n            final double weaknessAddend = e.isWeaknessModifierMultiplier() ? newDamage * weaknessModifier : weaknessModifier;\n            // Don't modify newDamage yet so both potion effects are calculated off of the base damage\n            debug(\"Weak: \" + weaknessAddend);\n            debug(\"Weak: \" + weaknessAddend, damager);\n\n            // Strength potion\n            debug(\"Strength level: \" + e.getStrengthLevel());\n            debug(\"Strength level: \" + e.getStrengthLevel(), damager);\n            double strengthModifier = e.getStrengthModifier() * e.getStrengthLevel();\n            if (!e.isStrengthModifierMultiplier()) newDamage += strengthModifier;\n            else if (e.isStrengthModifierAddend()) newDamage *= ++strengthModifier;\n            else newDamage *= strengthModifier;\n\n            debug(\"Strength: \" + strengthModifier);\n            debug(\"Strength: \" + strengthModifier, damager);\n\n            newDamage += weaknessAddend;\n\n            // Scale by attack delay\n            // float currentItemAttackStrengthDelay = 1.0D / GenericAttributes.ATTACK_SPEED * 20.0D\n            // attack strength ticker goes up by 1 every tick, is reset to 0 after an attack\n            // float f2 = MathHelper.clamp((attackStrengthTicker + 0.5) / currentItemAttackStrengthDelay, 0.0F, 1.0F);\n            // f *= 0.2F + f2 * f2 * 0.8F;\n            // the multiplier is equivalent to y = 0.8x^2 + 0.2\n            // because x (f2) is always between 0 and 1, the multiplier will always be between 0.2 and 1\n            // this implies 40 speed is the minimum to always have full attack strength\n            if (damager instanceof HumanEntity) {\n                final float cooldown = DamageUtils.getAttackCooldown.apply((HumanEntity) damager, 0.5F); // i.e. f2\n                debug(\"Scale by attack delay: \" + newDamage + \" *= 0.2 + \" + cooldown + \"^2 * 0.8\");\n                newDamage *= 0.2F + cooldown * cooldown * 0.8F;\n            }\n\n            // Critical hit\n            final double criticalMultiplier = e.getCriticalMultiplier();\n            debug(\"Crit \" + newDamage + \" *= \" + criticalMultiplier);\n            newDamage *= criticalMultiplier;\n\n            // Enchantment damage, scaled by attack cooldown\n            double enchantmentDamage = e.getMobEnchantmentsDamage() + e.getSharpnessDamage();\n            if (damager instanceof HumanEntity) {\n                final float cooldown = DamageUtils.getAttackCooldown.apply((HumanEntity) damager, 0.5F);\n                debug(\"Scale enchantments by attack delay: \" + enchantmentDamage + \" *= \" + cooldown);\n                enchantmentDamage *= cooldown;\n            }\n            newDamage += enchantmentDamage;\n            debug(\"Mob \" + e.getMobEnchantmentsDamage() + \" Sharp: \" + e.getSharpnessDamage() + \" Scaled: \" + enchantmentDamage, damager);\n\n            // Paper sword blocking (consumable-based, no shield)\n            final ModuleSwordBlocking swordBlocking = ModuleSwordBlocking.getInstance();\n            double paperBlockReduction = 0;\n            if (event instanceof EntityDamageByEntityEvent && swordBlocking != null) {\n                paperBlockReduction = swordBlocking.applyPaperBlockingReduction((EntityDamageByEntityEvent) event, newDamage);\n                if (paperBlockReduction > 0) {\n                    final double preBlockDamage = newDamage;\n                    newDamage = Math.max(0, newDamage - paperBlockReduction);\n                    ((EntityDamageByEntityEvent) event).setDamage(EntityDamageEvent.DamageModifier.BLOCKING, -paperBlockReduction);\n                    ((EntityDamageByEntityEvent) event).setDamage(EntityDamageEvent.DamageModifier.BASE, preBlockDamage);\n                    debug(\"Sword block (Paper): \" + preBlockDamage + \" - \" + paperBlockReduction + \" = \" + newDamage, damager);\n                }\n            }\n\n            if (damagee instanceof LivingEntity) {\n                // Overdamage due to immunity\n                // Invulnerability will cause less damage if they attack with a stronger weapon while vulnerable\n                // That is, the difference in damage will be dealt, but only if new attack is stronger than previous one\n                // Value before overdamage will become new \"last damage\"\n                newDamage = checkOverdamage(((LivingEntity) damagee), event, newDamage);\n            }\n\n            if (newDamage < 0) {\n                debug(\"Damage was \" + newDamage + \" setting to 0\", damager);\n                newDamage = 0;\n            }\n\n            // Set damage; if we already populated modifiers for blocking, avoid overwriting BASE.\n            if (paperBlockReduction > 0 && event instanceof EntityDamageByEntityEvent) {\n                ((EntityDamageByEntityEvent) event).setDamage(EntityDamageEvent.DamageModifier.BASE, newDamage + paperBlockReduction);\n                // BLOCKING was set earlier; total damage is BASE + BLOCKING (+ others)\n            } else {\n                event.setDamage(newDamage);\n            }\n            debug(\"New Damage: \" + newDamage, damager);\n            debug(\"Attack damage (before defence): \" + newDamage);\n        }\n    }\n\n    /**\n     * Set entity's last damage to 0 a tick after the event so all overdamage attacks get through.\n     * The last damage is overridden by NMS code regardless of what the actual damage is set to via Spigot.\n     * Finally, the LOWEST priority listener above will set the last damage back to the correct value\n     * for other plugins to use the next time the entity is damaged.\n     */\n    @EventHandler(priority = EventPriority.MONITOR)\n    public void afterEntityDamage(EntityDamageEvent event) {\n        final Entity damagee = event.getEntity();\n\n        if (event instanceof EntityDamageByEntityEvent) {\n            if (damagee instanceof LivingEntity && lastDamages.containsKey(damagee.getUniqueId())) {\n                // Set last damage to 0, so we can detect attacks even by weapons with a weaker attack value than what OCM would calculate\n                Bukkit.getScheduler().runTaskLater(plugin, () -> {\n                    ((LivingEntity) damagee).setLastDamage(0);\n                    debug(\"Set last damage to 0\", damagee);\n                    debug(\"Set last damage to 0\");\n                }, 1L);\n            }\n        } else {\n            // if not EDBYE then we leave last damage as is\n            if (damagee instanceof LivingEntity) {\n                final LivingEntity livingDamagee = (LivingEntity) damagee;\n                if ((float) livingDamagee.getNoDamageTicks() > (float) livingDamagee.getMaximumNoDamageTicks() / 2.0F\n                        && lastDamages.containsKey(livingDamagee.getUniqueId())) {\n                    debug(\"Non-entity damage inside invulnerability window, keeping stored last damage\", livingDamagee);\n                    debug(\"Non-entity damage inside invulnerability window, keeping stored last damage\");\n                    return;\n                }\n                clearStoredDamage(livingDamagee);\n            }\n            debug(\"Non-entity damage, using default last damage\", damagee);\n            debug(\"Non-entity damage, using default last damage\");\n        }\n    }\n\n    /**\n     * Restored the correct last damage for the given entity\n     *\n     * @param damagee The living entity to try to restore the last damage for\n     */\n    private void restoreLastDamage(LivingEntity damagee) {\n        final Double lastStoredDamage = resolveStoredDamage(damagee);\n        if (lastStoredDamage != null) {\n            final LivingEntity livingDamagee = damagee;\n            livingDamagee.setLastDamage(lastStoredDamage);\n            lastDamages.put(livingDamagee.getUniqueId(), lastStoredDamage);\n            touchExpiry(livingDamagee);\n            debug(\"Set last damage back to \" + lastStoredDamage, livingDamagee);\n            debug(\"Set last damage back to \" + lastStoredDamage);\n        } else {\n            debug(\"No stored last damage to restore\", damagee);\n            debug(\"No stored last damage to restore\");\n        }\n    }\n\n    private double checkOverdamage(LivingEntity livingDamagee, EntityDamageEvent event, double newDamage) {\n        final double incomingDamage = newDamage; // base damage (before defence), used for baseline tracking\n        final double newLastDamage = Math.max(0, incomingDamage);\n\n        /*\n         * Vanilla 1.12 EntityLiving#damageEntity(DamageSource, float) flow:\n         * - If noDamageTicks > maxNoDamageTicks / 2:\n         *     - If damage <= lastDamage -> return false (cancel)\n         *     - Else call damageEntity0(source, damage - lastDamage)\n         *     - Then lastDamage = damage\n         * - Else:\n         *     - Call damageEntity0(source, damage)\n         *     - lastDamage = damage\n         *     - Set noDamageTicks = maxNoDamageTicks, etc.\n         *\n         * This means any successful fire tick overwrites lastDamage, so we must restore\n         * the correct baseline before applying our overdamage checks.\n         */\n        if ((float) livingDamagee.getNoDamageTicks() > (float) livingDamagee.getMaximumNoDamageTicks() / 2.0F) {\n            // Last damage was either set to correct value above in this listener, or we're using the server's value\n            // If other plugins later modify BASE damage, they should either be taking last damage into account,\n            // or ignoring the event if it is cancelled\n            final Double storedDamage = resolveStoredDamage(livingDamagee);\n            final double lastDamage = storedDamage != null ? storedDamage : livingDamagee.getLastDamage();\n            if (newDamage <= lastDamage) {\n                event.setDamage(0);\n                event.setCancelled(true);\n                debug(\"Was fake overdamage, cancelling \" + newDamage + \" <= \" + lastDamage);\n                // Do not overwrite the stored baseline with this cancelled damage (e.g. fire tick),\n                // otherwise the next attack can incorrectly bypass immunity.\n                lastDamages.put(livingDamagee.getUniqueId(), lastDamage);\n                touchExpiry(livingDamagee);\n                return 0;\n            }\n\n            debug(\"Overdamage: \" + newDamage + \" - \" + lastDamage);\n            // We must subtract previous damage from new weapon damage for this attack\n            newDamage -= lastDamage;\n\n            debug(\"Last damage \" + lastDamage + \" new damage: \" + newLastDamage + \" applied: \" + newDamage\n                    + \" ticks: \" + livingDamagee.getNoDamageTicks() + \" /\" + livingDamagee.getMaximumNoDamageTicks()\n            );\n        }\n        // Update the last damage done, including when it was overdamage.\n        // This means attacks must keep increasing in value during immunity period to keep dealing overdamage.\n        lastDamages.put(livingDamagee.getUniqueId(), newLastDamage);\n        touchExpiry(livingDamagee);\n\n        return newDamage;\n    }\n\n    private Double resolveStoredDamage(LivingEntity damagee) {\n        final UUID uuid = damagee.getUniqueId();\n        final Long expiresAtTick = lastDamageExpiryTicks.get(uuid);\n        if (expiresAtTick != null && expiresAtTick <= tickCounter) {\n            lastDamageExpiryTicks.remove(uuid);\n            lastDamages.remove(uuid);\n            return null;\n        }\n        return lastDamages.get(uuid);\n    }\n\n    private void clearStoredDamage(LivingEntity damagee) {\n        final UUID uuid = damagee.getUniqueId();\n        lastDamageExpiryTicks.remove(uuid);\n        lastDamages.remove(uuid);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/MobDamage.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport com.google.common.collect.ImmutableMap;\nimport com.cryptomorin.xseries.XEnchantment;\nimport org.bukkit.enchantments.Enchantment;\nimport org.bukkit.entity.EntityType;\nimport org.bukkit.inventory.ItemStack;\n\nimport java.util.Map;\n\npublic class MobDamage {\n\n    private static final Map<EntityType, Enchantment> enchants;\n    private static final Enchantment SMITE = XEnchantment.SMITE.getEnchant();\n    private static final Enchantment BANE_OF_ARTHROPODS = XEnchantment.BANE_OF_ARTHROPODS.getEnchant();\n\n    static {\n        Map<String, Enchantment> allMobs = ImmutableMap.<String, Enchantment>builder()\n                // Undead (https://minecraft.gamepedia.com/Undead)\n                .put(\"SKELETON\", SMITE)\n                .put(\"ZOMBIE\", SMITE)\n                .put(\"WITHER\", SMITE)\n                .put(\"WITHER_SKELETON\", SMITE)\n                .put(\"ZOMBIFIED_PIGLIN\", SMITE)\n                .put(\"SKELETON_HORSE\", SMITE)\n                .put(\"STRAY\", SMITE)\n                .put(\"HUSK\", SMITE)\n                .put(\"PHANTOM\", SMITE)\n                .put(\"DROWNED\", SMITE)\n                .put(\"ZOGLIN\", SMITE)\n                .put(\"ZOMBIE_HORSE\", SMITE)\n                .put(\"ZOMBIE_VILLAGER\", SMITE)\n\n                // Arthropods (https://minecraft.gamepedia.com/Arthropod)\n                .put(\"SPIDER\", BANE_OF_ARTHROPODS)\n                .put(\"CAVE_SPIDER\", BANE_OF_ARTHROPODS)\n                .put(\"BEE\", BANE_OF_ARTHROPODS)\n                .put(\"SILVERFISH\", BANE_OF_ARTHROPODS)\n                .put(\"ENDERMITE\", BANE_OF_ARTHROPODS)\n\n                .build();\n\n        ImmutableMap.Builder<EntityType, Enchantment> enchantsBuilder = ImmutableMap.builder();\n\n        // Add these individually because some may not exist in the Minecraft version we're running\n        allMobs.keySet().forEach(entityName -> {\n            try {\n                final EntityType entityType = EntityType.valueOf(entityName);\n                final Enchantment enchantment = allMobs.get(entityName);\n                enchantsBuilder.put(entityType, enchantment);\n            } catch (IllegalArgumentException ignored) {\n            } // Mob not supported in this MC version\n        });\n        enchants = enchantsBuilder.build();\n    }\n\n    /**\n     * Gets damage due to Smite and Bane of Arthropods enchantments, when applicable\n     *\n     * @param entity The type of entity that was attacked\n     * @param item   The enchanted weapon used in the attack\n     * @return The damage due to the enchantments\n     */\n    public static double getEntityEnchantmentsDamage(EntityType entity, ItemStack item) {\n        final Enchantment enchantment = enchants.get(entity);\n\n        if (enchantment == null || enchantment != SMITE || enchantment != BANE_OF_ARTHROPODS)\n            return 0;\n\n        return 2.5 * item.getEnchantmentLevel(enchantment);\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/NewWeaponDamage.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport org.bukkit.Material;\n\n/**\n * Default 1.9 Minecraft tool damage values\n */\npublic enum NewWeaponDamage {\n\n    // common values\n    STONE_SWORD(5), STONE_SHOVEL(3.5F), STONE_PICKAXE(3), STONE_AXE(9), STONE_HOE(1),\n    IRON_SWORD(6), IRON_SHOVEL(4.5F), IRON_PICKAXE(4), IRON_AXE(9), IRON_HOE(1),\n    DIAMOND_SWORD(7), DIAMOND_SHOVEL(5.5F), DIAMOND_PICKAXE(5), DIAMOND_AXE(9), DIAMOND_HOE(1),\n\n    // pre-1.13 values\n    STONE_SPADE(3.5F), IRON_SPADE(4.5F), DIAMOND_SPADE(5.5F),\n    WOOD_SWORD(4), WOOD_SPADE(2.5F), WOOD_PICKAXE(2), WOOD_AXE(7), WOOD_HOE(1),\n    GOLD_SWORD(4), GOLD_SPADE(2.5F), GOLD_PICKAXE(2), GOLD_AXE(7), GOLD_HOE(1),\n\n    // post-1.13 values\n    WOODEN_SWORD(4), WOODEN_SHOVEL(2.5F), WOODEN_PICKAXE(2), WOODEN_AXE(7), WOODEN_HOE(1),\n    GOLDEN_SWORD(4), GOLDEN_SHOVEL(2.5F), GOLDEN_PICKAXE(2), GOLDEN_AXE(7), GOLDEN_HOE(1),\n    COPPER_SWORD(5), COPPER_SHOVEL(3.5F), COPPER_PICKAXE(3), COPPER_AXE(9), COPPER_HOE(1),\n    NETHERITE_SWORD(8), NETHERITE_SHOVEL(6.5F), NETHERITE_PICKAXE(6), NETHERITE_AXE(10), NETHERITE_HOE(1),\n\n    // Weapons introduced after 1.13\n    TRIDENT(8), // vanilla thrown + melee base (before impaling)\n    MACE(6);    // vanilla base damage; fall bonus is added separately in NMS\n\n    private final float damage;\n\n    NewWeaponDamage(float damage) {\n        this.damage = damage;\n    }\n\n    public static float getDamage(String mat) {\n        return valueOf(mat).damage;\n    }\n\n    public static float getDamage(Material mat) {\n        return getDamage(mat.toString());\n    }\n\n    public static Float getDamageOrNull(String mat) {\n        try {\n            return valueOf(mat).damage;\n        } catch (IllegalArgumentException ex) {\n            return null;\n        }\n    }\n\n    public static Float getDamageOrNull(Material mat) {\n        return getDamageOrNull(mat.toString());\n    }\n\n    public float getDamage() {\n        return damage;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/OCMEntityDamageByEntityEvent.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport com.cryptomorin.xseries.XEnchantment;\nimport com.cryptomorin.xseries.XPotion;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.PotionEffects;\nimport kernitus.plugin.OldCombatMechanics.utilities.potions.WeaknessCompensation;\nimport org.bukkit.Material;\nimport org.bukkit.entity.*;\nimport org.bukkit.event.Cancellable;\nimport org.bukkit.event.Event;\nimport org.bukkit.event.HandlerList;\nimport org.bukkit.event.entity.EntityDamageEvent.DamageCause;\nimport org.bukkit.inventory.ItemStack;\nimport org.bukkit.potion.PotionEffect;\nimport org.bukkit.potion.PotionEffectType;\nimport org.bukkit.NamespacedKey;\nimport org.bukkit.enchantments.Enchantment;\n\nimport java.util.HashSet;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static kernitus.plugin.OldCombatMechanics.utilities.Messenger.debug;\n\npublic class OCMEntityDamageByEntityEvent extends Event implements Cancellable {\n\n    private boolean cancelled;\n    private static final HandlerList handlers = new HandlerList();\n\n    @Override\n    public HandlerList getHandlers() {\n        return handlers;\n    }\n\n    public static HandlerList getHandlerList() {\n        return handlers;\n    }\n\n    private final Entity damager, damagee;\n    private final DamageCause cause;\n    private double rawDamage;\n\n    private ItemStack weapon;\n    private int sharpnessLevel;\n    private boolean hasWeakness;\n    // The levels as shown in-game, i.e. 1 or 2 corresponding to I and II\n    private int strengthLevel, weaknessLevel;\n\n    private double baseDamage = 0, mobEnchantmentsDamage = 0, sharpnessDamage = 0, criticalMultiplier = 1;\n    private double strengthModifier = 0, weaknessModifier = 0;\n\n    // In 1.9 strength modifier is an addend, in 1.8 it is a multiplier and addend (+130%)\n    private boolean isStrengthModifierMultiplier = false;\n    private boolean isStrengthModifierAddend = true;\n    private boolean isWeaknessModifierMultiplier = false;\n\n    private boolean was1_8Crit = false;\n    private boolean wasSprinting = false;\n    private static final Set<String> warnedUnknownWeaponEnchants = new HashSet<>();\n    private static final Set<Enchantment> VANILLA_ENCHANTMENTS = initVanillaEnchantments();\n\n    // Here we reverse-engineer all the various damages caused by removing them one at a time, backwards from what NMS code does.\n    // This is so the modules can listen to this event and make their modifications, then EntityDamageByEntityListener sets the new values back.\n    // Performs the opposite of the following:\n    // (Base + Potion effects, scaled by attack delay) + Critical Hit + (Enchantments, scaled by attack delay), Overdamage, Armour\n    public OCMEntityDamageByEntityEvent(Entity damager, Entity damagee, DamageCause cause, double rawDamage) {\n        this.damager = damager;\n        this.damagee = damagee;\n        this.cause = cause;\n\n        // We ignore attacks like arrows etc. because we do not need to change the attack side of those\n        // Other modules such as old armour strength work independently of this event\n        if (!(damager instanceof LivingEntity)) {\n            setCancelled(true);\n            return;\n        }\n\n        // The raw damage passed to this event is EDBE's BASE damage, which does not include armour effects or resistance etc (defence)\n        this.rawDamage = rawDamage;\n\n        /*\n        Invulnerability will cause less damage if they attack with a stronger weapon while vulnerable.\n        We must detect this and account for it, instead of setting the usual base weapon damage.\n        We artificially set the last damage to 0 between events so that all hits will register,\n        however we only do this for DamageByEntity, so there could still be environmental damage (e.g. cactus).\n        */\n        if (damagee instanceof LivingEntity) {\n            final LivingEntity livingDamagee = (LivingEntity) damagee;\n            if ((float) livingDamagee.getNoDamageTicks() > (float) livingDamagee.getMaximumNoDamageTicks() / 2.0F) {\n                // NMS code also checks if current damage is higher that previous damage. However, here the event\n                // already has the difference between the two as the raw damage, and the event does not fire at all\n                // if this precondition is not met.\n\n                // Adjust for last damage being environmental sources (e.g. cactus, fall damage)\n                final double lastDamage = livingDamagee.getLastDamage();\n                this.rawDamage = rawDamage + lastDamage;\n\n                debug(livingDamagee, \"Overdamaged!: \" + livingDamagee.getNoDamageTicks() + \"/\" +\n                        livingDamagee.getMaximumNoDamageTicks() + \" last: \" + livingDamagee.getLastDamage());\n            } else {\n                debug(livingDamagee, \"Invulnerability: \" + livingDamagee.getNoDamageTicks() + \"/\" +\n                        livingDamagee.getMaximumNoDamageTicks() + \" last: \" + livingDamagee.getLastDamage());\n            }\n        }\n\n        final LivingEntity livingDamager = (LivingEntity) damager;\n\n        weapon = livingDamager.getEquipment().getItemInMainHand();\n        // Yay paper. Why do you need to return null here?\n        if (weapon == null) weapon = new ItemStack(Material.AIR);\n        // Technically the weapon could be in the offhand, i.e. a bow.\n        // However, we are only concerned with melee weapons here, which will always be in the main hand.\n\n        final EntityType damageeType = damagee.getType();\n\n        warnOnUnknownWeaponEnchantments(weapon);\n\n        debug(livingDamager, \"Raw attack damage: \" + rawDamage);\n        debug(livingDamager, \"Without overdamage: \" + this.rawDamage);\n\n\n        mobEnchantmentsDamage = MobDamage.getEntityEnchantmentsDamage(damageeType, weapon);\n        sharpnessLevel = weapon.getEnchantmentLevel(XEnchantment.SHARPNESS.getEnchant());\n        sharpnessDamage = DamageUtils.getNewSharpnessDamage(sharpnessLevel);\n\n        // Scale enchantment damage by attack cooldown\n        if (damager instanceof HumanEntity) {\n            final float cooldown = DamageUtils.getAttackCooldown.apply((HumanEntity) damager, 0.5F);\n            mobEnchantmentsDamage *= cooldown;\n            sharpnessDamage *= cooldown;\n        }\n\n        debug(livingDamager, \"Mob: \" + mobEnchantmentsDamage + \" Sharpness: \" + sharpnessDamage);\n\n        // Amount of damage including potion effects and critical hits\n        double tempDamage = this.rawDamage - mobEnchantmentsDamage - sharpnessDamage;\n\n        debug(livingDamager, \"No ench damage: \" + tempDamage);\n\n        // Check if it's a critical hit\n        if (livingDamager instanceof Player && DamageUtils.isCriticalHit1_8((HumanEntity) livingDamager)){\n            was1_8Crit = true;\n            debug(livingDamager, \"1.8 Critical hit detected\");\n            // In 1.9 a crit also requires the player not to be sprinting\n            if (DamageUtils.isCriticalHit1_9((Player) livingDamager)) {\n                debug(livingDamager, \"1.9 Critical hit detected\");\n                debug(\"1.9 Critical hit detected\");\n                criticalMultiplier = 1.5;\n                tempDamage /= 1.5;\n            }\n        }\n\n        // Un-scale the damage by the attack strength\n        if (damager instanceof HumanEntity) {\n            final float cooldown = DamageUtils.getAttackCooldown.apply((HumanEntity) damager, 0.5F);\n            tempDamage /= 0.2F + cooldown * cooldown * 0.8F;\n        }\n\n        // amplifier 0 = Strength I    amplifier 1 = Strength II\n        strengthLevel = PotionEffects.get(livingDamager, XPotion.STRENGTH.get())\n                .map(PotionEffect::getAmplifier)\n                .orElse(-1) + 1;\n\n        // Store per-level modifier so listeners can multiply by level consistently.\n        strengthModifier = strengthLevel > 0 ? 3 : 0;\n\n        debug(livingDamager, \"Strength Modifier: \" + strengthModifier);\n\n        // Don't set has weakness if amplifier is < -1, which is outside normal range and probably set by a plugin\n        // We use an amplifier of -1 (Level 0) to have no effect so weaker attacks will register\n        // Any positive amplifier is treated as \"weakness present\" for old-combat behaviour\n        final Optional<Integer> weaknessAmplifier = PotionEffects.get(livingDamager, PotionEffectType.WEAKNESS)\n                .map(PotionEffect::getAmplifier);\n        final int weaknessValue = weaknessAmplifier.orElse(-1);\n        if (weaknessAmplifier.isPresent() && weaknessValue >= -1) {\n            hasWeakness = true;\n            final int rawWeaknessLevel = weaknessValue + 1;\n            weaknessLevel = Math.min(rawWeaknessLevel, 1);\n        } else {\n            hasWeakness = false;\n            weaknessLevel = 0;\n        }\n\n        weaknessModifier = weaknessLevel * -4;\n\n        debug(livingDamager, \"Weakness Modifier: \" + weaknessModifier);\n\n        final boolean weaknessCompensated = WeaknessCompensation.hasModifier(livingDamager);\n        final double weaknessForBase = weaknessCompensated ? 0 : weaknessModifier;\n        if (weaknessCompensated) {\n            debug(livingDamager, \"Weakness compensated; skipping base weakness modifier\");\n        }\n\n        baseDamage = tempDamage + weaknessForBase - (strengthModifier * strengthLevel);\n        debug(livingDamager, \"Base tool damage: \" + baseDamage);\n    }\n\n    private static void warnOnUnknownWeaponEnchantments(ItemStack weapon) {\n        if (weapon == null || weapon.getEnchantments().isEmpty()) {\n            return;\n        }\n        final Enchantment sharpness = XEnchantment.SHARPNESS.getEnchant();\n        final Enchantment smite = XEnchantment.SMITE.getEnchant();\n        final Enchantment bane = XEnchantment.BANE_OF_ARTHROPODS.getEnchant();\n        for (Enchantment enchantment : weapon.getEnchantments().keySet()) {\n            if (enchantment == null || enchantment.equals(sharpness) || enchantment.equals(smite) || enchantment.equals(bane)) {\n                continue;\n            }\n            if (!shouldWarnOnUnknownEnchantment(enchantment)) {\n                continue;\n            }\n            final String name = enchantment.getName();\n            if (warnedUnknownWeaponEnchants.add(name)) {\n                kernitus.plugin.OldCombatMechanics.utilities.Messenger.warn(\n                        \"Weapon enchantment '%s' is not modelled by OCM damage calculations; results may differ\",\n                        name);\n            }\n        }\n    }\n\n    private static boolean shouldWarnOnUnknownEnchantment(Enchantment enchantment) {\n        try {\n            final NamespacedKey key = enchantment.getKey();\n            if (key != null) {\n                return !\"minecraft\".equals(key.getNamespace());\n            }\n        } catch (NoSuchMethodError | NoClassDefFoundError ignored) {\n            // Legacy servers do not expose NamespacedKey; fall back to known vanilla list.\n        }\n        return !isVanillaEnchantment(enchantment);\n    }\n\n    private static Set<Enchantment> initVanillaEnchantments() {\n        final Set<Enchantment> enchantments = new HashSet<>();\n        for (XEnchantment xEnchantment : XEnchantment.values()) {\n            final Enchantment enchantment = xEnchantment.getEnchant();\n            if (enchantment != null) {\n                enchantments.add(enchantment);\n            }\n        }\n        return enchantments;\n    }\n\n    private static boolean isVanillaEnchantment(Enchantment enchantment) {\n        return VANILLA_ENCHANTMENTS.contains(enchantment);\n    }\n\n    public Entity getDamager() {\n        return damager;\n    }\n\n    public Entity getDamagee() {\n        return damagee;\n    }\n\n    public DamageCause getCause() {\n        return cause;\n    }\n\n    public double getRawDamage() {\n        return rawDamage;\n    }\n\n    public ItemStack getWeapon() {\n        return weapon;\n    }\n\n    public int getSharpnessLevel() {\n        return sharpnessLevel;\n    }\n\n    public double getStrengthModifier() {\n        return strengthModifier;\n    }\n\n    public void setStrengthModifier(double strengthModifier) {\n        this.strengthModifier = strengthModifier;\n    }\n\n    public int getStrengthLevel() {\n        return strengthLevel;\n    }\n\n    /**\n     * Whether the attacker had the weakness potion effect,\n     * and the level of the effect was either 0 (used by OCM) or 1 (normal value).\n     * Values outside this range are to be ignored, as they are probably from other plugins.\n     */\n    public boolean hasWeakness() {\n        return hasWeakness;\n    }\n\n    public int getWeaknessLevel() {\n        return weaknessLevel;\n    }\n\n    public double getWeaknessModifier() {\n        return weaknessModifier;\n    }\n\n    public void setWeaknessModifier(double weaknessModifier) {\n        this.weaknessModifier = weaknessModifier;\n    }\n\n    public void setWeaknessLevel(int weaknessLevel) {\n        this.weaknessLevel = weaknessLevel;\n    }\n\n    public boolean isStrengthModifierMultiplier() {\n        return isStrengthModifierMultiplier;\n    }\n\n    public void setIsStrengthModifierMultiplier(boolean isStrengthModifierMultiplier) {\n        this.isStrengthModifierMultiplier = isStrengthModifierMultiplier;\n    }\n\n    public void setIsStrengthModifierAddend(boolean isStrengthModifierAddend) {\n        this.isStrengthModifierAddend = isStrengthModifierAddend;\n    }\n\n    public boolean isWeaknessModifierMultiplier() {\n        return isWeaknessModifierMultiplier;\n    }\n\n    public void setIsWeaknessModifierMultiplier(boolean weaknessModifierMultiplier) {\n        isWeaknessModifierMultiplier = weaknessModifierMultiplier;\n    }\n\n    public boolean isStrengthModifierAddend() {\n        return isStrengthModifierAddend;\n    }\n\n    public double getBaseDamage() {\n        return baseDamage;\n    }\n\n    public void setBaseDamage(double baseDamage) {\n        this.baseDamage = baseDamage;\n    }\n\n    public double getMobEnchantmentsDamage() {\n        return mobEnchantmentsDamage;\n    }\n\n    public void setMobEnchantmentsDamage(double mobEnchantmentsDamage) {\n        this.mobEnchantmentsDamage = mobEnchantmentsDamage;\n    }\n\n    public double getSharpnessDamage() {\n        return sharpnessDamage;\n    }\n\n    public void setSharpnessDamage(double sharpnessDamage) {\n        this.sharpnessDamage = sharpnessDamage;\n    }\n\n    public double getCriticalMultiplier() {\n        return criticalMultiplier;\n    }\n\n    public void setCriticalMultiplier(double criticalMultiplier) {\n        this.criticalMultiplier = criticalMultiplier;\n    }\n\n    @Override\n    public boolean isCancelled() {\n        return cancelled;\n    }\n\n    @Override\n    public void setCancelled(boolean cancelled) {\n        this.cancelled = cancelled;\n    }\n\n    public boolean wasSprinting() {\n        return wasSprinting;\n    }\n\n    public void setWasSprinting(boolean wasSprinting) {\n        this.wasSprinting = wasSprinting;\n    }\n\n    public boolean was1_8Crit() {\n        return was1_8Crit;\n    }\n\n    public void setWas1_8Crit(boolean was1_8Crit) {\n        this.was1_8Crit = was1_8Crit;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/damage/WeaponDamages.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.damage;\n\nimport com.cryptomorin.xseries.XMaterial;\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;\nimport org.bukkit.Material;\nimport org.bukkit.configuration.ConfigurationSection;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class WeaponDamages {\n\n    private static Map<String, Double> damages;\n\n    private static OCMMain plugin;\n\n    public static void initialise(OCMMain plugin) {\n        WeaponDamages.plugin = plugin;\n        reload();\n    }\n\n    private static void reload() {\n        final ConfigurationSection section = plugin.getConfig().getConfigurationSection(\"old-tool-damage.damages\");\n        damages = ConfigUtils.loadDoubleMap(section);\n    }\n\n    public static double getDamage(Material mat) {\n        final String name = mat.name().replace(\"GOLDEN\", \"GOLD\").replace(\"WOODEN\", \"WOOD\").replace(\"SHOVEL\", \"SPADE\");\n        return damages.getOrDefault(name, -1.0);\n    }\n\n    public static double getDamage(String key) {\n        return damages.getOrDefault(key, -1.0);\n    }\n\n    public static Map<Material, Double> getMaterialDamages() {\n        final Map<Material, Double> materialMap = new HashMap<>();\n        damages.forEach((name, damage) -> {\n            final String newName = name.replace(\"GOLD\", \"GOLDEN\").replace(\"WOOD\", \"WOODEN\").replace(\"SPADE\", \"SHOVEL\");\n            XMaterial.matchXMaterial(newName).ifPresent(xmaterial -> {\n                final Material material = xmaterial.parseMaterial();\n                if (material != null) {\n                    materialMap.put(material, damage);\n                }\n            });\n        });\n        return materialMap;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/potions/PotionDurations.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.potions;\n\n/**\n * Hold information on duration of drinkable & splash version of a potion type\n */\npublic final class PotionDurations {\n    private final int drinkable;\n    private final int splash;\n\n    public PotionDurations(int drinkable, int splash) {\n        this.drinkable = drinkable;\n        this.splash = splash;\n    }\n\n    public int drinkable() {\n        return drinkable;\n    }\n\n    public int splash() {\n        return splash;\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/potions/PotionEffects.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.potions;\n\nimport kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser;\nimport org.bukkit.entity.LivingEntity;\nimport org.bukkit.potion.PotionEffect;\nimport org.bukkit.potion.PotionEffectType;\n\nimport java.util.Optional;\n\npublic class PotionEffects {\n\n    private static final SpigotFunctionChooser<LivingEntity, PotionEffectType, PotionEffect> getPotionEffectsFunction =\n            SpigotFunctionChooser.apiCompatCall((le, type) -> le.getPotionEffect(type),\n                    (le, type) ->\n                            le.getActivePotionEffects().stream()\n                                    .filter(potionEffect -> potionEffect.getType().equals(type))\n                                    .findAny()\n                                    .orElse(null)\n            );\n\n    /**\n     * Returns the {@link PotionEffect} of a given {@link PotionEffectType} for a given {@link LivingEntity}, if present.\n     *\n     * @param entity the entity to query\n     * @param type   the type to search\n     * @return the {@link PotionEffect} if present\n     */\n    public static Optional<PotionEffect> get(LivingEntity entity, PotionEffectType type) {\n        return Optional.ofNullable(getOrNull(entity, type));\n    }\n\n    /**\n     * Returns the {@link PotionEffect} of a given {@link PotionEffectType} for a given {@link LivingEntity}, if present.\n     *\n     * @param entity the entity to query\n     * @param type   the type to search\n     * @return the {@link PotionEffect} or null if not present\n     */\n    public static PotionEffect getOrNull(LivingEntity entity, PotionEffectType type) {\n        return getPotionEffectsFunction.apply(entity, type);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/potions/PotionKey.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.potions;\n\nimport com.cryptomorin.xseries.XPotion;\nimport org.bukkit.inventory.meta.PotionMeta;\nimport org.bukkit.potion.PotionData;\nimport org.bukkit.potion.PotionType;\n\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.Optional;\n\n/**\n * Key for potion duration configuration, based on effect and strength/length flags.\n */\npublic final class PotionKey {\n    private static final String STRONG_PREFIX = \"STRONG_\";\n    private static final String LONG_PREFIX = \"LONG_\";\n\n    private final XPotion potion;\n    private final boolean strong;\n    private final boolean extended;\n\n    private PotionKey(XPotion potion, boolean strong, boolean extended) {\n        this.potion = Objects.requireNonNull(potion, \"potion\");\n        this.strong = strong;\n        this.extended = extended;\n    }\n\n    public XPotion getPotion() {\n        return potion;\n    }\n\n    public boolean isStrong() {\n        return strong;\n    }\n\n    public boolean isExtended() {\n        return extended;\n    }\n\n    public boolean isPotion(XPotion target) {\n        return potion == target;\n    }\n\n    public String getDebugName() {\n        if (strong) {\n            return STRONG_PREFIX + potion.name();\n        }\n        if (extended) {\n            return LONG_PREFIX + potion.name();\n        }\n        return potion.name();\n    }\n\n    public static Optional<PotionKey> fromConfigKey(String key) {\n        if (key == null) {\n            return Optional.empty();\n        }\n        String name = key.toUpperCase(Locale.ROOT);\n        boolean strong = false;\n        boolean extended = false;\n\n        if (name.startsWith(STRONG_PREFIX)) {\n            strong = true;\n            name = name.substring(STRONG_PREFIX.length());\n        } else if (name.startsWith(LONG_PREFIX)) {\n            extended = true;\n            name = name.substring(LONG_PREFIX.length());\n        }\n\n        return fromBaseName(name, strong, extended);\n    }\n\n    public static Optional<PotionKey> fromPotionMeta(PotionMeta potionMeta) {\n        if (potionMeta == null) {\n            return Optional.empty();\n        }\n\n        try {\n            PotionType potionType = potionMeta.getBasePotionType();\n            if (potionType == null) {\n                return Optional.empty();\n            }\n            return fromPotionTypeName(potionType.name(), false, false);\n        } catch (NoSuchMethodError e) {\n            PotionData potionData = potionMeta.getBasePotionData();\n            return fromPotionTypeName(potionData.getType().name(), potionData.isUpgraded(), potionData.isExtended());\n        }\n    }\n\n    private static Optional<PotionKey> fromPotionTypeName(String name, boolean upgraded, boolean extended) {\n        String baseName = name.toUpperCase(Locale.ROOT);\n        boolean strong = upgraded;\n        boolean longDuration = extended;\n\n        if (baseName.startsWith(STRONG_PREFIX)) {\n            strong = true;\n            baseName = baseName.substring(STRONG_PREFIX.length());\n        } else if (baseName.startsWith(LONG_PREFIX)) {\n            longDuration = true;\n            baseName = baseName.substring(LONG_PREFIX.length());\n        }\n\n        return fromBaseName(baseName, strong, longDuration);\n    }\n\n    private static Optional<PotionKey> fromBaseName(String baseName, boolean strong, boolean extended) {\n        Optional<XPotion> potion = XPotion.matchXPotion(baseName);\n        if (!potion.isPresent()) {\n            return Optional.empty();\n        }\n\n        XPotion found = potion.get();\n        if (found == XPotion.INSTANT_DAMAGE || found == XPotion.INSTANT_HEALTH) {\n            return Optional.empty();\n        }\n\n        return Optional.of(new PotionKey(found, strong, extended));\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (this == other) {\n            return true;\n        }\n        if (!(other instanceof PotionKey)) {\n            return false;\n        }\n        PotionKey that = (PotionKey) other;\n        return strong == that.strong && extended == that.extended && potion == that.potion;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(potion, strong, extended);\n    }\n\n    @Override\n    public String toString() {\n        return \"PotionKey{\" + getDebugName() + \"}\";\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/potions/WeaknessCompensation.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.potions;\n\nimport com.cryptomorin.xseries.XAttribute;\nimport org.bukkit.attribute.Attribute;\nimport org.bukkit.attribute.AttributeInstance;\nimport org.bukkit.attribute.AttributeModifier;\nimport org.bukkit.entity.LivingEntity;\n\nimport java.util.UUID;\n\npublic final class WeaknessCompensation {\n    public static final UUID MODIFIER_UUID = UUID.fromString(\"5cf9f56c-7b95-4d39-9e1f-7c0b4c84f26c\");\n    private static final String MODIFIER_NAME = \"OCM-Weakness-Compensation\";\n    private static final double MODIFIER_AMOUNT = 4.0;\n\n    private WeaknessCompensation() {\n    }\n\n    public static boolean hasModifier(LivingEntity entity) {\n        final AttributeInstance attribute = getAttackDamageAttribute(entity);\n        if (attribute == null) return false;\n        for (AttributeModifier modifier : attribute.getModifiers()) {\n            if (MODIFIER_UUID.equals(modifier.getUniqueId())) return true;\n        }\n        return false;\n    }\n\n    public static void apply(LivingEntity entity) {\n        final AttributeInstance attribute = getAttackDamageAttribute(entity);\n        if (attribute == null || hasModifier(entity)) return;\n        final AttributeModifier modifier = new AttributeModifier(\n                MODIFIER_UUID,\n                MODIFIER_NAME,\n                MODIFIER_AMOUNT,\n                AttributeModifier.Operation.ADD_NUMBER\n        );\n        attribute.addModifier(modifier);\n    }\n\n    public static void remove(LivingEntity entity) {\n        final AttributeInstance attribute = getAttackDamageAttribute(entity);\n        if (attribute == null) return;\n        for (AttributeModifier modifier : attribute.getModifiers()) {\n            if (MODIFIER_UUID.equals(modifier.getUniqueId())) {\n                attribute.removeModifier(modifier);\n            }\n        }\n    }\n\n    private static AttributeInstance getAttackDamageAttribute(LivingEntity entity) {\n        final Attribute attribute = XAttribute.ATTACK_DAMAGE.get();\n        if (attribute == null) return null;\n        return entity.getAttribute(attribute);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/reflection/Reflector.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.reflection;\n\nimport org.bukkit.Bukkit;\n\nimport java.lang.reflect.*;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.BiFunction;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\npublic class Reflector {\n    private static String version;\n    private static int majorVersion, minorVersion, patchVersion;\n\n    static {\n        try {\n            // Split on the \"-\" to just get the version information\n            version = Bukkit.getServer().getBukkitVersion().split(\"-\")[0];\n            final String[] splitVersion = version.split(\"\\\\.\");\n\n            majorVersion = Integer.parseInt(splitVersion[0]);\n            minorVersion = Integer.parseInt(splitVersion[1]);\n            if(splitVersion.length > 2) {\n                patchVersion = Integer.parseInt(splitVersion[2]);\n            } else {\n                patchVersion = 0;\n            }\n        } catch (Exception e) {\n            System.err.println(\"Failed to load Reflector: \" + e.getMessage());\n        }\n    }\n\n    public static String getVersion() {\n        return version;\n    }\n\n    /**\n     * Checks if the current server version is newer or equal to the one provided.\n     *\n     * @param major the target major version\n     * @param minor the target minor version. 0 for all\n     * @param patch the target patch version. 0 for all\n     * @return true if the server version is newer or equal to the one provided\n     */\n    public static boolean versionIsNewerOrEqualTo(int major, int minor, int patch) {\n        if (getMajorVersion() < major) return false;\n        if (getMinorVersion() < minor) return false;\n        return getPatchVersion() >= patch;\n    }\n\n    private static int getMajorVersion() {\n        return majorVersion;\n    }\n\n    private static int getMinorVersion() {\n        return minorVersion;\n    }\n\n    private static int getPatchVersion() {\n        return patchVersion;\n    }\n\n    public static Class<?> getClass(String fqn) {\n        try {\n            return Class.forName(fqn);\n        } catch (ClassNotFoundException e) {\n            throw new RuntimeException(\"Couldn't load class \" + fqn, e);\n        }\n    }\n\n    public static Method getMethod(Class<?> clazz, String name) {\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredMethods()),\n                        Arrays.stream(clazz.getMethods())\n                )\n                .filter(method -> method.getName().equals(name))\n                .peek(method -> method.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    public static Method getMethod(Class<?> clazz, String name, int parameterCount) {\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredMethods()),\n                        Arrays.stream(clazz.getMethods())\n                )\n                .filter(method -> method.getName().equals(name) && method.getParameterCount() == parameterCount)\n                .peek(method -> method.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    public static Method getMethod(Class<?> clazz, Class<?> returnType, String... parameterTypeSimpleNames){\n        List<String> typeNames = Arrays.asList(parameterTypeSimpleNames);\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredMethods()),\n                        Arrays.stream(clazz.getMethods())\n                )\n                .filter(method -> method.getReturnType() == returnType)\n                .filter(it -> getParameterNames.apply(it).equals(typeNames))\n                .peek(method -> method.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    private static final Function<Method, List<String>> getParameterNames = method -> Arrays\n            .stream(method.getParameters())\n            .map(Parameter::getType)\n            .map(Class::getSimpleName)\n            .collect(Collectors.toList());\n\n    public static Method getMethod(Class<?> clazz, String name, String... parameterTypeSimpleNames) {\n        List<String> typeNames = Arrays.asList(parameterTypeSimpleNames);\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredMethods()),\n                        Arrays.stream(clazz.getMethods())\n                )\n                .filter(it -> it.getName().equals(name))\n                .filter(it -> getParameterNames.apply(it).equals(typeNames))\n                .peek(it -> it.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    /**\n     * Finds a method by name where the provided parameter types are assignable to the method parameters.\n     * Null entries in {@code parameterTypes} act as wildcards.\n     */\n    public static Method getMethodAssignable(Class<?> clazz, String name, Class<?>... parameterTypes) {\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredMethods()),\n                        Arrays.stream(clazz.getMethods())\n                )\n                .filter(it -> it.getName().equals(name))\n                .filter(it -> it.getParameterCount() == parameterTypes.length)\n                .filter(it -> areParametersAssignable(it.getParameterTypes(), parameterTypes))\n                .peek(it -> it.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    public static Method getMethodByGenericReturnType(TypeVariable<?> typeVar, Class<?> clazz){\n        for (Method method : clazz.getMethods()){\n            if (method.getGenericReturnType().getTypeName().equals(typeVar.getName())){\n                return method;\n            }\n        }\n        throw new RuntimeException(\"Method with type \" + typeVar + \" not found\");\n    }\n\n\n    public static <T> T invokeMethod(Method method, Object handle, Object... params) {\n        try {\n            @SuppressWarnings(\"unchecked\")\n            T t = (T) method.invoke(handle, params);\n            return t;\n        } catch (IllegalAccessException | InvocationTargetException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    /**\n     * Resolves the given method, caches it and then uses that instance for all future invocations.\n     * <p>\n     * The returned function just invokes the cached method for a given target.\n     *\n     * @param clazz the clazz the method is in\n     * @param name  the name of the method\n     * @param <T>   the type of the handle\n     * @param <U>   the type of the parameter(s)\n     * @param <R>   the type of the method result\n     * @return a function that invokes the retrieved cached method for its argument\n     */\n    public static <T, U, R> BiFunction<T, U, R> memoiseMethodInvocation(Class<T> clazz, String name, String... argTypes) {\n        final Method method = getMethod(clazz, name, argTypes);\n        return (t, u) -> {\n            // If they did not want to send any arguments, should be zero-length array\n            // This check is necessary cause of varargs, otherwise we get 1 length array of 0-length array\n            if(u instanceof Object[] && ((Object[]) u).length == 0)\n                return invokeMethod(method, t);\n\n            return invokeMethod(method, t, u);\n        };\n    }\n\n    public static Field getField(Class<?> clazz, String fieldName) {\n        try {\n            Field field = clazz.getDeclaredField(fieldName);\n            field.setAccessible(true);\n            return field;\n        } catch (NoSuchFieldException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static Field getFieldByType(Class<?> clazz, String simpleClassName) {\n        for (Field declaredField : clazz.getDeclaredFields()) {\n            if (declaredField.getType().getSimpleName().equals(simpleClassName)) {\n                declaredField.setAccessible(true);\n                return declaredField;\n            }\n        }\n        throw new RuntimeException(\"Field with type \" + simpleClassName + \" not found\");\n    }\n\n    public static Field getMapFieldWithTypes(Class<?> clazz, Class<?> keyType, Class<?> valueType) {\n        for (Field field : clazz.getDeclaredFields()) {\n            // Check if the field is a Map\n            if (Map.class.isAssignableFrom(field.getType())) {\n                // Get the generic type of the field\n                final Type genericType = field.getGenericType();\n                if (genericType instanceof ParameterizedType) {\n                    final ParameterizedType parameterizedType = (ParameterizedType) genericType;\n                    final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();\n                    // Check if the map's key and value types match the specified classes\n                    if (actualTypeArguments.length == 2 &&\n                            actualTypeArguments[0].equals(keyType) &&\n                            actualTypeArguments[1].equals(valueType)) {\n                        field.setAccessible(true);\n                        return field;\n                    }\n                }\n            }\n        }\n        throw new RuntimeException(\"Map field with key type \" + keyType.getSimpleName() +\n                \" and value type \" + valueType.getSimpleName() + \" not found\");\n    }\n\n    public static Object getFieldValueByType(Object object, String simpleClassName) throws Exception {\n        Stream<Field> publicFields = Stream.of(object.getClass().getFields());\n        Stream<Field> declaredFields = Stream.of(object.getClass().getDeclaredFields());\n        Stream<Field> allFields = Stream.concat(publicFields, declaredFields);\n\n        // Find the first field that matches the type name\n        Field matchingField = allFields\n                .filter(declaredField -> declaredField.getType().getSimpleName().equals(simpleClassName))\n                .findFirst()\n                .orElseThrow(() -> new NoSuchFieldException(\"Couldn't find field with type \" + simpleClassName + \" in \" + object.getClass()));\n\n        // Make the field accessible and return its value\n        matchingField.setAccessible(true);\n        return matchingField.get(object);\n    }\n\n    public static Object getFieldValue(Field field, Object handle) {\n        field.setAccessible(true);\n        try {\n            return field.get(handle);\n        } catch (IllegalAccessException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static void setFieldValue(Field field, Object handle, Object value) {\n        field.setAccessible(true);\n        try {\n            field.set(handle, value);\n        } catch (IllegalAccessException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static Constructor<?> getConstructor(Class<?> clazz, int numParams) {\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredConstructors()),\n                        Arrays.stream(clazz.getConstructors())\n                )\n                .filter(constructor -> constructor.getParameterCount() == numParams)\n                .peek(it -> it.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    public static Constructor<?> getConstructor(Class<?> clazz, String... parameterTypeSimpleNames) {\n        Function<Constructor<?>, List<String>> getParameterNames = constructor -> Arrays\n                .stream(constructor.getParameters())\n                .map(Parameter::getType)\n                .map(Class::getSimpleName)\n                .collect(Collectors.toList());\n        List<String> typeNames = Arrays.asList(parameterTypeSimpleNames);\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredConstructors()),\n                        Arrays.stream(clazz.getConstructors())\n                )\n                .filter(constructor -> getParameterNames.apply(constructor).equals(typeNames))\n                .peek(it -> it.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    /**\n     * Finds a constructor where the provided parameter types are assignable to the constructor parameters.\n     * Null entries in {@code parameterTypes} act as wildcards.\n     */\n    public static Constructor<?> getConstructorAssignable(Class<?> clazz, Class<?>... parameterTypes) {\n        return Stream.concat(\n                        Arrays.stream(clazz.getDeclaredConstructors()),\n                        Arrays.stream(clazz.getConstructors())\n                )\n                .filter(constructor -> constructor.getParameterCount() == parameterTypes.length)\n                .filter(constructor -> areParametersAssignable(constructor.getParameterTypes(), parameterTypes))\n                .peek(it -> it.setAccessible(true))\n                .findFirst()\n                .orElse(null);\n    }\n\n    /**\n     * Attempts to resolve an enum constant by name, trying each provided name in order.\n     */\n    public static Object getEnumConstant(Class<?> enumClass, String... names) {\n        if (!enumClass.isEnum()) {\n            throw new IllegalArgumentException(enumClass.getName() + \" is not an enum\");\n        }\n        @SuppressWarnings(\"unchecked\")\n        Class<? extends Enum> typedEnum = (Class<? extends Enum>) enumClass;\n        for (String name : names) {\n            if (name == null) continue;\n            try {\n                return Enum.valueOf(typedEnum, name);\n            } catch (IllegalArgumentException ignored) {\n                // try next\n            }\n            for (Object constant : enumClass.getEnumConstants()) {\n                Enum<?> enumConstant = (Enum<?>) constant;\n                if (enumConstant.name().equalsIgnoreCase(name) || enumConstant.toString().equals(name)) {\n                    return enumConstant;\n                }\n            }\n        }\n        throw new IllegalArgumentException(\"No enum constant found in \" + enumClass.getName());\n    }\n\n    private static boolean areParametersAssignable(Class<?>[] target, Class<?>[] provided) {\n        if (target.length != provided.length) return false;\n        for (int i = 0; i < target.length; i++) {\n            Class<?> providedType = provided[i];\n            if (providedType == null) continue;\n            if (!target[i].isAssignableFrom(providedType)) return false;\n        }\n        return true;\n    }\n\n    /**\n     * Checks if a given class <i>somehow</i> inherits from another class\n     *\n     * @param toCheck        The class to check\n     * @param inheritedClass The inherited class, it should have\n     * @return True if {@code toCheck} somehow inherits from\n     * {@code inheritedClass}\n     */\n    public static boolean inheritsFrom(Class<?> toCheck, Class<?> inheritedClass) {\n        if (inheritedClass.isAssignableFrom(toCheck)) {\n            return true;\n        }\n\n        for (Class<?> implementedInterface : toCheck.getInterfaces()) {\n            if (inheritsFrom(implementedInterface, inheritedClass)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    public static <T> T getUnchecked(UncheckedReflectionSupplier<T> supplier) {\n        try {\n            return supplier.get();\n        } catch (ReflectiveOperationException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static void doUnchecked(UncheckedReflectionRunnable runnable) {\n        try {\n            runnable.run();\n        } catch (ReflectiveOperationException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public interface UncheckedReflectionSupplier<T> {\n        T get() throws ReflectiveOperationException;\n    }\n\n    public interface UncheckedReflectionRunnable {\n        void run() throws ReflectiveOperationException;\n    }\n\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/reflection/SpigotFunctionChooser.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\npackage kernitus.plugin.OldCombatMechanics.utilities.reflection;\n\nimport java.util.function.BiFunction;\n\n/**\n * Chooses a Spigot API function to use\n * Chooses a function to apply based on a test supplier, remembers the choice\n * and only uses the corresponding\n * function in the future.\n * <p>\n * The branch to pick is determined during the <em>first execution of its\n * {@link #apply(Object, Object)} method!</em>.\n * This means that no matter how often the feature branch is invoked, it will\n * never reconsider its choice.\n *\n * @param <T> the type of the entity to apply the function to\n * @param <U> the type of the extra parameter(s). Use {@link Object} if unused,\n *            or list of objects if multiple.\n * @param <R> the return type of the function\n */\npublic class SpigotFunctionChooser<T, U, R> {\n\n    private final BiFunction<T, U, Boolean> test;\n    private final BiFunction<T, U, R> trueBranch;\n    private final BiFunction<T, U, R> falseBranch;\n    private BiFunction<T, U, R> chosen;\n\n    /**\n     * Creates a new {@link SpigotFunctionChooser}, which chooses between two given\n     * functions.\n     *\n     * @param test        the test supplier that will be invoked to choose a branch\n     * @param trueBranch  the branch to pick when then test is true\n     * @param falseBranch the branch to pick when then test is false\n     */\n    public SpigotFunctionChooser(BiFunction<T, U, Boolean> test, BiFunction<T, U, R> trueBranch,\n            BiFunction<T, U, R> falseBranch) {\n        this.test = test;\n        this.trueBranch = trueBranch;\n        this.falseBranch = falseBranch;\n    }\n\n    /**\n     * Applies the stored action to the given target and chooses what branch to use\n     * on the first call.\n     *\n     * @param target     the target to apply it to\n     * @param parameters the extra parameters to pass to the function\n     * @return the result of applying the function to the given target\n     */\n    public R apply(T target, U parameters) {\n        if (chosen == null) {\n            synchronized (this) {\n                if (chosen == null) {\n                    chosen = test.apply(target, parameters) ? trueBranch : falseBranch;\n                }\n            }\n        }\n        return chosen.apply(target, parameters);\n    }\n\n    /**\n     * Version without extra parameter(s) of {@link #apply(Object, Object)}\n     */\n    @SuppressWarnings(\"unchecked\")\n    public R apply(T target) {\n        return apply(target, (U) new Object[0]);\n    }\n\n    /**\n     * Creates a {@link SpigotFunctionChooser} that uses the success parameter when\n     * the action completes without an\n     * exception and otherwise uses the failure parameter.\n     * <p>\n     * The action is, per the doc for {@link SpigotFunctionChooser} only called\n     * <em>once</em>.\n     *\n     * @param action  the action to invoke\n     * @param success the branch to take when no exception occurs\n     * @param failure the branch to take when an exception occurs\n     * @param <T>     the type of the class containing the method\n     * @param <U>     the type of the parameter(s)\n     * @param <R>     the return type of the method\n     * @return a {@link SpigotFunctionChooser} that picks the branch based on\n     *         whether action threw an exception\n     */\n    private static <T, U, R> SpigotFunctionChooser<T, U, R> onException(ExceptionalFunction<T, U, R> action,\n            BiFunction<T, U, R> success,\n            BiFunction<T, U, R> failure) {\n        return new SpigotFunctionChooser<>(\n                (t, u) -> {\n                    try {\n                        action.apply(t, u);\n                        return true;\n                    } catch (ExceptionalFunction.WrappedException e) {\n                        final Throwable failureCause = rootCause(e);\n                        if (isCompatibilityFailure(failureCause)) {\n                            return false;\n                        }\n                        throw propagate(failureCause);\n                    }\n                },\n                success, failure);\n    }\n\n    private static Throwable rootCause(Throwable throwable) {\n        Throwable current = throwable;\n        while (current.getCause() != null && current.getCause() != current) {\n            current = current.getCause();\n        }\n        return current;\n    }\n\n    private static RuntimeException propagate(Throwable throwable) {\n        if (throwable instanceof RuntimeException) {\n            throw (RuntimeException) throwable;\n        }\n        if (throwable instanceof Error) {\n            throw (Error) throwable;\n        }\n        throw new RuntimeException(throwable);\n    }\n\n    private static boolean isCompatibilityFailure(Throwable throwable) {\n        if (throwable == null) {\n            return false;\n        }\n        if (throwable instanceof LinkageError) {\n            return true;\n        }\n        if (throwable instanceof NoSuchMethodException || throwable instanceof ClassNotFoundException) {\n            return true;\n        }\n        if (throwable instanceof UnsupportedOperationException) {\n            return isApprovedUnsupportedOperation((UnsupportedOperationException) throwable);\n        }\n        return false;\n    }\n\n    private static boolean isApprovedUnsupportedOperation(UnsupportedOperationException exception) {\n        final String className = exception.getClass().getSimpleName();\n        if (className != null && className.toLowerCase().startsWith(\"compat\")) {\n            return true;\n        }\n\n        final String message = exception.getMessage();\n        if (message == null) {\n            return false;\n        }\n\n        final String normalised = message.toLowerCase();\n        return normalised.matches(\".*(^|[^a-z])compat(ibility)?([^a-z]|$).*\");\n    }\n\n    /**\n     * Calls the Spigot API method if possible, otherwise uses reflection to access\n     * same method.\n     * Useful for API methods that were only added after a certain version. Caches\n     * chosen method for performance.\n     *\n     * <p>\n     * Note: 1.16 is last version with Spigot-mapped fields, 1.17 with Spigot-mapped\n     * methods.\n     *\n     * @param apiCall A reference to the function that should be called\n     * @param clazz   The class containing the function to be accessed via\n     *                reflection\n     * @param name    The name of the function to be accessed via reflection\n     * @return A new instance of {@link SpigotFunctionChooser}\n     */\n    public static <T, U, R> SpigotFunctionChooser<T, U, R> apiCompatReflectionCall(ExceptionalFunction<T, U, R> apiCall,\n            Class<T> clazz, String name,\n            String... argTypes) {\n        return onException(apiCall, apiCall, Reflector.memoiseMethodInvocation(clazz, name, argTypes));\n    }\n\n    /**\n     * Calls the Spigot API method if possible, otherwise uses the provided function\n     * as a workaround.\n     * <p>\n     * This should be used to avoid reflection wherever possible, making the plugin\n     * more compatible.\n     * Chosen method is cached for performance. Do not use method references as they\n     * are eagerly-bound in Java 8.\n     * </p>\n     *\n     * @param apiCall A reference to the function that should be called\n     * @param altFunc A function that should instead be called if API method not\n     *                available.\n     * @return A new instance of {@link SpigotFunctionChooser}\n     */\n    public static <T, U, R> SpigotFunctionChooser<T, U, R> apiCompatCall(ExceptionalFunction<T, U, R> apiCall,\n            BiFunction<T, U, R> altFunc) {\n        return onException(apiCall, apiCall, altFunc);\n    }\n\n    @FunctionalInterface\n    public interface ExceptionalFunction<T, U, R> extends BiFunction<T, U, R> {\n        // Compatibility policy: API-compat fallbacks must only trigger for missing/binary-incompatible API\n        // failures (for example LinkageError and reflection discovery failures) plus explicit\n        // compatibility-style UnsupportedOperationException signals (for example class names beginning with\n        // \"compat\" or message tokens \"compat\"/\"compatibility\", but not generic wording like\n        // \"incompatible\"). Ordinary runtime logic failures are\n        // rethrown and must not\n        // silently select the fallback branch.\n\n        /**\n         * Called by {@link #apply(Object, Object)}, this method is the target of the\n         * functional interface and where you can\n         * write your logic, that might throw an exception.\n         *\n         * @param t the function argument\n         * @return the function result\n         */\n        R applyWithException(T t, U params) throws Throwable;\n\n        /**\n         * {@inheritDoc}\n         *\n         * @param t {@inheritDoc}\n         * @return {@inheritDoc}\n         * @throws WrappedException if any *Throwable* is thrown\n         */\n        @Override\n        default R apply(T t, U u) {\n            try {\n                return applyWithException(t, u);\n            } catch (Throwable e) {\n                throw new WrappedException(e);\n            }\n        }\n\n        class WrappedException extends RuntimeException {\n            WrappedException(Throwable cause) {\n                super(cause);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/reflection/VersionCompatUtils.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.reflection;\n\nimport org.bukkit.entity.HumanEntity;\nimport org.bukkit.entity.LivingEntity;\n\nimport java.lang.reflect.Method;\nimport java.util.Map;\nimport java.util.WeakHashMap;\n\n/**\n * Utilities to help with keeping compatibility across multiple versions of the\n * game.\n */\npublic class VersionCompatUtils {\n    private static Method cooldownMethod;\n    private static final Map<Class<?>, Method> absorptionAmountMethodCache = new WeakHashMap<>();\n    private static final Map<Class<?>, Method> handleMethodCache = new WeakHashMap<>();\n\n    /**\n     * Returns a Craft object from the given Spigot object, e.g. CraftPlayer from\n     * Player.\n     * Useful for accessing properties not available through Spigot's API.\n     *\n     * @param spigotObject The spigot object to get the handle of, e.g. Player\n     * @return The Craft object\n     */\n    private static Object getCraftHandle(Object spigotObject) {\n        final Class<?> clazz = spigotObject.getClass();\n        Method handle = handleMethodCache.get(clazz);\n        if (handle == null) {\n            handle = Reflector.getMethod(clazz, \"getHandle\");\n            handleMethodCache.put(clazz, handle);\n        }\n        return Reflector.invokeMethod(handle, spigotObject);\n    }\n\n    public static float getAttackCooldown(HumanEntity he) {\n        final Object craftHumanEntity = getCraftHandle(he);\n        // public float x(float a), grab by return and param type, cause name changes in\n        // each version\n        if (cooldownMethod == null) // cache this to not search for it every single time\n            cooldownMethod = Reflector.getMethod(craftHumanEntity.getClass(), float.class, \"float\");\n        return Reflector.invokeMethod(cooldownMethod, craftHumanEntity, 0.5F);\n    }\n\n    public static float getAbsorptionAmount(LivingEntity livingEntity) {\n        final Object craftLivingEntity = getCraftHandle(livingEntity);\n        final Class<?> leClass = craftLivingEntity.getClass();\n        final Method absorptionAmountMethod;\n        // Cache method for each subclass of LivingEntity to not search for it every\n        // single time\n        // Cannot cache for LE itself because method is obtained from each subclass\n        if (!absorptionAmountMethodCache.containsKey(leClass)) {\n            absorptionAmountMethod = Reflector.getMethod(craftLivingEntity.getClass(), \"getAbsorptionHearts\");\n            absorptionAmountMethodCache.put(leClass, absorptionAmountMethod);\n        } else {\n            absorptionAmountMethod = absorptionAmountMethodCache.get(leClass);\n        }\n\n        // Give useful debugging information in case the method cannot be applied\n        if (!absorptionAmountMethod.getDeclaringClass().isAssignableFrom(craftLivingEntity.getClass())) {\n            throw new IllegalArgumentException(\n                    \"Cannot call method '\" + absorptionAmountMethod + \"' of class '\"\n                            + absorptionAmountMethod.getDeclaringClass().getName()\n                            + \"' using object '\" + craftLivingEntity + \"' of class '\"\n                            + craftLivingEntity.getClass().getName() + \"' because\"\n                            + \" object '\" + craftLivingEntity + \"' is not an instance of '\"\n                            + absorptionAmountMethod.getDeclaringClass().getName() + \"'\");\n        }\n\n        return Reflector.invokeMethod(absorptionAmountMethod, craftLivingEntity);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/storage/ModesetListener.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.storage;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport kernitus.plugin.OldCombatMechanics.module.OCMModule;\nimport kernitus.plugin.OldCombatMechanics.utilities.Config;\nimport kernitus.plugin.OldCombatMechanics.utilities.Messenger;\nimport org.bukkit.World;\nimport org.bukkit.entity.Player;\nimport org.bukkit.event.EventHandler;\nimport org.bukkit.event.EventPriority;\nimport org.bukkit.event.player.PlayerChangedWorldEvent;\nimport org.bukkit.event.player.PlayerJoinEvent;\nimport org.bukkit.event.world.WorldLoadEvent;\nimport org.bukkit.event.world.WorldUnloadEvent;\n\nimport java.util.Set;\nimport java.util.UUID;\n\n/**\n * Listens to players changing world / spawning etc.\n * and updates modeset accordingly\n */\npublic class ModesetListener extends OCMModule {\n\n    public ModesetListener(OCMMain plugin) {\n        super(plugin, \"modeset-listener\");\n    }\n\n    @EventHandler(priority = EventPriority.LOWEST)\n    public void onPlayerChangedWorld(PlayerChangedWorldEvent event) {\n        final Player player = event.getPlayer();\n        final UUID playerId = player.getUniqueId();\n        final PlayerData playerData = PlayerStorage.getPlayerData(playerId);\n        final String modesetFromName = playerData.getModesetForWorld(event.getFrom().getUID());\n        updateModeset(player, player.getWorld().getUID(), modesetFromName);\n    }\n\n    private static void updateModeset(Player player, UUID worldId, String modesetFromName) {\n        final UUID playerId = player.getUniqueId();\n        final PlayerData playerData = PlayerStorage.getPlayerData(playerId);\n        final String originalModeset = playerData.getModesetForWorld(worldId);\n        String modesetName = playerData.getModesetForWorld(worldId);\n\n        // Get modesets allowed in to world\n        Set<String> allowedModesets = Config.getWorlds().get(worldId);\n        if (allowedModesets == null || allowedModesets.isEmpty())\n            allowedModesets = Config.getModesets().keySet();\n\n        // If they don't have a modeset in toWorld yet\n        if (modesetName == null) {\n            // Try to use modeset of world they are coming from\n            if (modesetFromName != null && allowedModesets.contains(modesetFromName))\n                modesetName = modesetFromName;\n            else // Otherwise, if the from modeset is not allowed, use default for to world\n                modesetName = allowedModesets.stream().findFirst().orElse(null);\n        }\n\n        // If the modeset changed, set and save\n        if (originalModeset == null || !originalModeset.equals(modesetName)) {\n            playerData.setModesetForWorld(worldId, modesetName);\n            PlayerStorage.setPlayerData(playerId, playerData);\n            PlayerStorage.scheduleSave();\n\n            Messenger.send(player,\n                    Config.getConfig().getString(\"mode-messages.mode-set\",\n                            \"&4ERROR: &rmode-messages.mode-set string missing\"),\n                    modesetName\n            );\n        }\n    }\n\n    @EventHandler(priority = EventPriority.LOWEST)\n    public void onPlayerJoin(PlayerJoinEvent event) {\n        final Player player = event.getPlayer();\n        updateModeset(player, player.getWorld().getUID(), null);\n    }\n\n    @EventHandler(ignoreCancelled = false)\n    public void onWorldLoad(WorldLoadEvent event) {\n        final World world = event.getWorld();\n        Config.addWorld(world);\n        Messenger.info(\"Loaded configured world \" + world.getName());\n    }\n\n    @EventHandler(ignoreCancelled = false)\n    public void onWorldUnload(WorldUnloadEvent event) {\n        final World world = event.getWorld();\n        Config.removeWorld(world);\n        Messenger.info(\"Unloaded configured world \" + world.getName());\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/storage/PlayerData.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.storage;\n\nimport org.bson.Document;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class PlayerData {\n    private Map<UUID, String> modesetByWorld;\n\n    public PlayerData() {\n        modesetByWorld = new HashMap<>();\n    }\n\n    public Map<UUID, String> getModesetByWorld() {\n        return modesetByWorld;\n    }\n\n    public void setModesetByWorld(Map<UUID, String> modesetByWorld) {\n        this.modesetByWorld = modesetByWorld;\n    }\n\n    public void setModesetForWorld(UUID worldId, String modeset) {\n        modesetByWorld.put(worldId, modeset);\n    }\n\n    public @Nullable String getModesetForWorld(UUID worldId) {\n        return modesetByWorld.get(worldId);\n    }\n\n    public static PlayerData fromDocument(Document doc) {\n        final PlayerData playerData = new PlayerData();\n        final Document modesetByWorldDoc = (Document) doc.get(\"modesetByWorld\");\n        if (modesetByWorldDoc != null) {\n            for (Map.Entry<String, Object> entry : modesetByWorldDoc.entrySet()) {\n                UUID worldId = UUID.fromString(entry.getKey());\n                String modeset = (String) entry.getValue();\n                playerData.setModesetForWorld(worldId, modeset);\n            }\n        }\n        return playerData;\n    }\n}"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/storage/PlayerDataCodec.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.storage;\n\nimport org.bson.BsonReader;\nimport org.bson.BsonWriter;\nimport org.bson.Document;\nimport org.bson.codecs.Codec;\nimport org.bson.codecs.DecoderContext;\nimport org.bson.codecs.DocumentCodec;\nimport org.bson.codecs.EncoderContext;\n\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class PlayerDataCodec implements Codec<PlayerData> {\n\n    @Override\n    public void encode(BsonWriter writer, PlayerData value, EncoderContext encoderContext) {\n        final Document document = new Document();\n        Document modesetByWorldDoc = new Document();\n        for (Map.Entry<UUID, String> entry : value.getModesetByWorld().entrySet()) {\n            modesetByWorldDoc.put(entry.getKey().toString(), entry.getValue());\n        }\n        document.put(\"modesetByWorld\", modesetByWorldDoc);\n        new DocumentCodec().encode(writer, document, encoderContext);\n    }\n\n    @Override\n    public Class<PlayerData> getEncoderClass() {\n        return PlayerData.class;\n    }\n\n    @Override\n    public PlayerData decode(BsonReader reader, DecoderContext decoderContext) {\n        final Document document = new DocumentCodec().decode(reader, decoderContext);\n        return PlayerData.fromDocument(document);\n    }\n}\n"
  },
  {
    "path": "src/main/java/kernitus/plugin/OldCombatMechanics/utilities/storage/PlayerStorage.java",
    "content": "/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\n/*\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n\npackage kernitus.plugin.OldCombatMechanics.utilities.storage;\n\nimport kernitus.plugin.OldCombatMechanics.OCMMain;\nimport org.bson.*;\nimport org.bson.codecs.*;\nimport org.bson.codecs.configuration.CodecRegistries;\nimport org.bson.codecs.configuration.CodecRegistry;\nimport org.bson.io.BasicOutputBuffer;\nimport org.bukkit.Bukkit;\nimport org.bukkit.scheduler.BukkitTask;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.UUID;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.logging.Level;\n\n/**\n * Stores data associated with players to disk, persisting across server restarts.\n */\npublic class PlayerStorage {\n\n    private static OCMMain plugin;\n    private static Path dataFilePath;\n    private static DocumentCodec documentCodec;\n    private static Document data;\n    private static final AtomicReference<BukkitTask> saveTask = new AtomicReference<>();\n    private static CodecRegistry codecRegistry;\n\n    public static void initialise(OCMMain plugin) {\n        PlayerStorage.plugin = plugin;\n        dataFilePath = Paths.get(plugin.getDataFolder() + File.separator + \"players.bson\");\n\n        codecRegistry = CodecRegistries.fromRegistries(\n                CodecRegistries.fromCodecs(new DocumentCodec()), // Explicitly provide a DocumentCodec\n                CodecRegistries.fromCodecs(new PlayerDataCodec()),\n                CodecRegistries.fromProviders(new BsonValueCodecProvider(), new ValueCodecProvider()) // For BSON values\n        );\n\n        PlayerStorage.documentCodec = new DocumentCodec(codecRegistry);\n\n        data = loadData();\n\n        saveTask.set(null);\n    }\n\n    private static Document loadData() {\n        if (Files.notExists(dataFilePath)) return new Document();\n\n        try {\n            byte[] data = Files.readAllBytes(dataFilePath);\n            final BsonReader reader = new BsonBinaryReader(ByteBuffer.wrap(data));\n            return documentCodec.decode(reader, DecoderContext.builder().build());\n        } catch (IOException e) {\n            plugin.getLogger().log(Level.SEVERE, \"Error loading player data\", e);\n        }\n        return new Document();\n    }\n\n    public static void scheduleSave() {\n        // Schedule a task for later, if there isn't one already scheduled\n        saveTask.compareAndSet(null,\n                Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, () -> {\n                    instantSave();\n                    saveTask.set(null);\n                }, 2400L) // Save after 2 minutes\n        );\n    }\n\n    public static void instantSave() {\n        final BasicOutputBuffer outputBuffer = new BasicOutputBuffer();\n        final BsonWriter writer = new BsonBinaryWriter(outputBuffer);\n        documentCodec.encode(writer, data, EncoderContext.builder().isEncodingCollectibleDocument(true).build());\n        writer.flush();\n\n        try {\n            Files.write(dataFilePath, outputBuffer.toByteArray());\n        } catch (IOException e) {\n            plugin.getLogger().log(Level.SEVERE, \"Error saving player data\", e);\n        } finally {\n            outputBuffer.close();\n        }\n    }\n\n    public static PlayerData getPlayerData(UUID uuid) {\n        final Document playerDoc = (Document) data.get(uuid.toString());\n        if (playerDoc == null) {\n            final PlayerData playerData = new PlayerData();\n            setPlayerData(uuid, playerData);\n            scheduleSave();\n            return playerData;\n        }\n        final BsonDocument bsonDocument = new BsonDocumentWrapper<>(playerDoc, documentCodec);\n        return codecRegistry.get(PlayerData.class).decode(bsonDocument.asBsonReader(), DecoderContext.builder().build());\n    }\n\n    public static void setPlayerData(UUID uuid, PlayerData playerData) {\n        // Create a BsonDocumentWriter to hold the encoded data\n        BsonDocumentWriter writer = new BsonDocumentWriter(new BsonDocument());\n\n        // Get the PlayerDataCodec from the CodecRegistry\n        PlayerDataCodec playerDataCodec = (PlayerDataCodec) codecRegistry.get(PlayerData.class);\n\n        // Encode the PlayerData object to the writer\n        playerDataCodec.encode(writer, playerData, EncoderContext.builder().isEncodingCollectibleDocument(true).build());\n\n        // Retrieve the BsonDocument\n        BsonDocument bsonDocument = writer.getDocument();\n\n        // Convert the BsonDocument to a Document\n        Document document = new Document();\n        bsonDocument.forEach((key, value) -> document.put(key, value.isDocument() ? new Document(value.asDocument()) : value));\n\n        // Put the Document into your data map\n        data.put(uuid.toString(), document);\n    }\n}"
  },
  {
    "path": "src/main/resources/config.yml",
    "content": "# This Source Code Form is subject to the terms of the Mozilla Public\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\n# ############# OldCombatMechanics Plugin by kernitus and Rayzr522 ##########\n#                                                                           #\n# Bukkit Page: http://dev.bukkit.org/bukkit-plugins/oldcombatmechanics/     #\n# Spigot Page: https://www.spigotmc.org/resources/oldcombatmechanics.19510/ #\n# GitHub Page: https://github.com/kernitus/BukkitOldCombatMechanics/        #\n#                                                                           #\n# ###########################################################################\n\nalways_enabled_modules:\n  # Modules listed here are always enabled, regardless of modeset.\n  # Each configurable module must appear in exactly one of: always_enabled_modules, disabled_modules, or a modeset.\n  # Internal modules (modeset-listener, attack-cooldown-tracker, entity-damage-listener) are managed by the plugin and must not be listed.\n  # If a module is listed in multiple sections or none, the plugin will throw an error.\n  - \"attack-frequency\"\n  - \"old-armour-durability\"\n  - \"old-fishing-knockback\"\n  - \"fishing-rod-velocity\"\n  - \"projectile-knockback\"\n  - \"disable-crafting\"\n  - \"update-checker\"\n  - \"old-brewing-stand\"\n  - \"disable-enderpearl-cooldown\"\n\ndisabled_modules:\n  # Modules listed here are always disabled, regardless of modeset.\n  # Of course, disabling a module which disables something will enable that something!\n  - \"disable-offhand\"\n  - \"disable-sword-sweep-particles\"\n  - \"disable-attack-sounds\"\n  - \"chorus-fruit\"\n  - \"old-burn-delay\"\n  - \"attack-range\"\n\nmodesets:\n  # Modesets are lists of modules that are enabled for a player in that mode.\n  # You can create, remove, and rename as many modesets as you like by modifying the list below.\n  # When in PvP, the modeset of the attacker is checked first.\n  # If not PvP, the modeset of the defending entity is checked.\n  # PlaceholderAPI: %ocm_modeset%\n  old:\n    - \"disable-attack-cooldown\"\n    - \"disable-sword-sweep\"\n    - \"old-tool-damage\"\n    - \"sword-blocking\"\n    - \"shield-damage-reduction\"\n    - \"old-golden-apples\"\n    - \"old-player-knockback\"\n    - \"old-player-regen\"\n    - \"old-armour-strength\"\n    - \"old-potion-effects\"\n    - \"old-critical-hits\"\n  new:\n    - \"old-golden-apples\"\n\nworlds:\n  # These are the modesets available in each world.\n  # If player has no modeset when moving worlds they'll be assigned first mode in list,\n  # unless the mode from the world they are coming from is also available in the new world.\n  # Worlds not specified below will have all modesets available.\n  world: [ \"old\", \"new\" ]\n  world_nether: [ \"old\", \"new\" ]\n  world_the_end: [ \"old\", \"new\" ]\n  # old_world: [\"old\"]\n  # brave_new_world: [\"new\"]\n\nmode-messages:\n  # Messages used when changing player mode\n  # To disable any message, leave the corresponding string empty (e.g., mode-status: \"\").\n  # This applies to all messages in the configuration.\n  mode-status: \"&bYour current modeset is: &7%s\"\n  message-usage: \"&eYou can use &c/ocm mode <modeset> [player] &eto change modeset\"\n  invalid-modeset: \"&cPlease specify a valid modeset!\"\n  invalid-player: \"&cPlease specify a valid player!\"\n  mode-set: \"&2Set modeset to &7%s\"\n\n# ########################\n# COMBAT MODULE SETTINGS\n# ########################\n\ndisable-attack-cooldown:\n  # This is to disable the attack cooldown\n  # What to set the attack speed to. Default for 1.9 is 4, at least 40 is needed for no cooldown.\n  generic-attack-speed: 40\n  # Optional per-material override for the item in the player's main hand.\n  # Materials not listed here fall back to generic-attack-speed.\n  # The bundled defaults keep most items at no cooldown, while trident, mace,\n  # and spears retain more vanilla-like attack speeds.\n  # Newer-version material keys that are unavailable on the current server are\n  # ignored safely.\n  held-item-attack-speeds:\n    TRIDENT: 1.1\n    MACE: 0.6\n    WOODEN_SPEAR: 1.54\n    GOLDEN_SPEAR: 1.05\n    STONE_SPEAR: 1.33\n    COPPER_SPEAR: 1.18\n    IRON_SPEAR: 1.05\n    DIAMOND_SPEAR: 0.95\n    NETHERITE_SPEAR: 0.87\n\nattack-frequency:\n  # Allows changing the player invulnerability between hits\n  # The hit delay to apply. Default for 1.9+ is 20 ticks (1 second)\n  playerDelay: 18\n  mobDelay: 16\n\nattack-range:\n  # Applies 1.8-style hit detection (smaller hitbox margin, shorter creative reach).\n  # Paper 1.21.11+ only; auto-disables on older/Paperless servers.\n  # Note: this module re-applies reach when items are held and strips it when swapping/dropping to avoid tainting stored items.\n  # Very frequent hotbar swapping may have a small performance cost.\n  min-range: 0.0\n  max-range: 3.0\n  min-creative-range: 0.0\n  max-creative-range: 4.0\n  hitbox-margin: 0.1\n  mob-factor: 1.0\n\nold-tool-damage:\n  # This is to set the tool damage as in pre-1.9\n  # IMPORTANT: Also enable disable-sword-sweep module or sweeps will have the damage value of the weapon in hand\n  # NOTE: this will modify the damage, however the vanilla attribute tooltip will still show the 1.9+ values.\n  # The tooltip section below is enabled by default so players can see the configured damage in-game.\n  # Use old sharpness calculations, i.e. each level adds 1.25 damage\n  # In 1.9+, sharpness adds 1 + 0.5 * level damage\n  old-sharpness: true\n  tooltip:\n    # Optionally adds a lore line showing the configured damage from old-tool-damage.damages (the actual damage OCM applies).\n    # This is a cosmetic helper to reduce confusion, but can interact with other plugins that rewrite item lore.\n    enabled: true\n    prefix: \"OCM Damage:\"\n  # Damage values are in 1.9+ \"actual damage\" terms (1 damage = half a heart).\n  # In 1.8, the client tooltip showed \"+X Attack Damage\", where X = (actual_damage - 1) because the base unarmed damage is 1.\n  damages:\n    # Axe damages\n    GOLD_AXE: 4\n    WOOD_AXE: 4\n    STONE_AXE: 5\n    IRON_AXE: 6\n    DIAMOND_AXE: 7\n    NETHERITE_AXE: 8\n    COPPER_AXE: 6\n    # Shovel damages\n    GOLD_SPADE: 2\n    WOOD_SPADE: 2\n    STONE_SPADE: 3\n    IRON_SPADE: 4\n    DIAMOND_SPADE: 5\n    NETHERITE_SPADE: 6\n    COPPER_SPADE: 3\n    # Sword damages\n    GOLD_SWORD: 5\n    WOOD_SWORD: 5\n    STONE_SWORD: 6\n    IRON_SWORD: 7\n    DIAMOND_SWORD: 8\n    NETHERITE_SWORD: 9\n    COPPER_SWORD: 6\n    # Pickaxe damages\n    GOLD_PICKAXE: 3\n    WOOD_PICKAXE: 3\n    STONE_PICKAXE: 4\n    IRON_PICKAXE: 5\n    DIAMOND_PICKAXE: 6\n    NETHERITE_PICKAXE: 7\n    COPPER_PICKAXE: 4\n    # Hoe damages\n    GOLD_HOE: 1\n    WOOD_HOE: 1\n    STONE_HOE: 1\n    IRON_HOE: 1\n    DIAMOND_HOE: 1\n    NETHERITE_HOE: 1\n    COPPER_HOE: 1\n    # Trident and mace\n    TRIDENT: 8\n    TRIDENT_THROWN: 8\n    MACE: 6\n\nold-critical-hits:\n  # Makes critical hits work like in 1.8\n  # With a critical hit, the damage will be multiplied by 1.5\n  # In 1.9, the user must also not be sprinting for it to be a crit\n  world: [ ]\n  # What the damage, after applying potions effects, is multiplied by\n  multiplier: 1.5\n  # Whether to allow crits while sprinting. 1.8: true, 1.9: false\n  allow-sprinting: true\n\nold-player-regen:\n  # This is to make players' regeneration act mostly like it did in pre-1.9\n  # Based on https://minecraft.gamepedia.com/Hunger?oldid=948685\n  # How often a player should regenerate health, in milliseconds (In 1.8: 4 seconds)\n  # The foodTickerTimer might not be perfectly accurate so we give it ~10ms of leeway\n  interval: 3990\n  # How many half-hearts the player should heal by, every seconds specified above\n  amount: 1\n  # How much exhaustion the player should get from healing. In 1.8: 3    In 1.9: 4    In 1.11: 6\n  # If, after adding this, Minecraft finds the value is above 4, it subtracts 4\n  # and either reduces saturation or, if saturation is 0, reduces food level by 1 (1/2 a stick)\n  exhaustion: 3\n\n# ########################\n# ARMOUR\n# ########################\n\nold-armour-strength:\n  # This is to make armour calculations like in 1.8\n  # Based on this: https://minecraft.gamepedia.com/index.php?title=Armor&oldid=909187\n  # Whether to introduce randomness in the calculation, as in 1.8\n  randomness: true\n\nold-armour-durability:\n  # This makes armour take a constant amount of durability damage (except for explosions)\n  # By how much to reduce durability every attack. 1.8 default is 1\n  reduction: 1\n\n# ########################\n# SWEEP, SHIELDS & BLOCKING\n# ########################\n\nshield-damage-reduction:\n  # This module allows changing the damage reduction behaviour of shields\n  # How much damage blocking should reduce\n  # Firstly, amount is subtracted, then value is multiplied by percentage\n  # 1.8: (damage - 1) * 50%    1.9: damage * 33%   1.11: damage * 0%\n  # Damage reduction = (damage - damageReductionAmount) * damageReductionPercentage / 100\n  generalDamageReductionAmount: 1\n  generalDamageReductionPercentage: 50\n  # This value works the same but is exclusively for projectile damage\n  # Set amount to 0 and percentage to 100 for 1.8 behaviour, i.e. arrows go through shields\n  projectileDamageReductionAmount: 1\n  projectileDamageReductionPercentage: 50\n\nsword-blocking:\n  # This is to allow players to block with swords again, by getting a shield while they hold right click with a sword\n  # Paper 1.20.5+ uses a consumable-based blocking animation. Older/Paperless servers fall back to an offhand shield.\n  # With ViaVersion, clients older than 1.20.5 will also fall back to the shield behaviour.\n  # Set to false to disable the Paper animation path entirely and always use the legacy shield fallback.\n  paper-animation: true\n  # How often, in ticks, OCM should check if the player is still blocking with a shield, and remove it if not\n  # If this is too fast, the player will have their shield disappear before they're able to block again causing a slight delay\n  # If this is too slow, players will have a shield in their hand well after they've stopped blocking\n  # 20 ticks = 1 second\n  restoreDelay: 40\n  # Whether to require players to have oldcombatmechanics.swordblock permission to block with a sword\n  use-permission: false\n\ndisable-sword-sweep:\n  # This is to disable the sword sweep attack\n  # With PacketEvents, particle effect is also removed\n\ndisable-sword-sweep-particles:\n  # This is to disable the sword sweep attack particles\n  # Requires PacketEvents\n\n# ########################\n# KNOCKBACK\n# ########################\n\nold-player-knockback:\n  # This is to change knockback players receive from attacks.  Default values are as in 1.8.\n  #\n  # Practice servers tend to use lower knockback, for example:\n  # knockback-horizontal: 0.35\n  # knockback-vertical: 0.35\n  # knockback-vertical-limit: 0.4\n  # knockback-extra-horizontal: 0.425\n  # knockback-extra-vertical: 0.085\n  #\n  # Minigame servers use higher vertical knockback and lower horizontal knockback, exact values are unknown.\n  # Horizontal knockback is reduced by 40% for every successful attack by the player, with no limit\n  # Increase to make clicking more important, decrease to make it less important\n  knockback-horizontal: 0.4\n  # Vertical knockback is not reduced by clicking faster\n  # Increase to make clicking less important, decrease to make clicking more important\n  knockback-vertical: 0.4\n  # Vertical knockback limit is applied after base vertical knockback\n  # This limit can be exceeded by sprint hitting or knockback enchantments, from the extra vertical knockback\n  knockback-vertical-limit: 0.4\n  # Extra horizontal knockback is applied for each level of knockback enchant, and for sprinting\n  # Increase to make sprint resetting (w-tapping) more important, decrease to make it less important\n  # Increase to make clicking more important, decrease to make clicking less important\n  knockback-extra-horizontal: 0.5\n  # Extra vertical knockback is applied for each level of knockback enchant, and for sprinting\n  # Increase to make sprint resetting (w-tapping) more important, decrease to make it less important\n  # Increase to make clicking less important, decrease to make clicking more important\n  knockback-extra-vertical: 0.1\n  # Should knockback resistance be enabled? (e.g. netherite armour knockback resistance)\n  enable-knockback-resistance: false\n\nold-fishing-knockback:\n  # This is to make the knockback of players when they get hit by a fishing bobber the same as it was in pre-1.9\n  # This is the damage done by the fishing rod attack\n  damage: 0.0001\n  # This is to cancel dragging in the entity attached to the fishing rod when reeling in, like in 1.8\n  # Options: all, players, mobs, none. players allows compatibility with WorldGuard pvp-deny regions\n  cancelDraggingIn: players\n  # Whether to also give knockback on non-player living entities (e.g. mobs)\n  knockbackNonPlayerEntities: false\n  # This is the delay in milliseconds in-between rod damage, so the player hit has time to fall back down\n  hitCooldown: 1000\n\nfishing-rod-velocity:\n  # In 1.9+ fishing rods go 8 blocks instead of 12 blocks\n  # This is due to both gravity and initial launch speed\n  # Set to true to revert back to the old calculations and gravity\n\nprojectile-knockback:\n  # This adds knockback and/or damage to players when they get hit by snowballs, eggs & enderpearls\n  # This has been a Bukkit bug for so long people thought it was vanilla when it was patched\n  # This is the damage done by each projectile\n  damage:\n    snowball: 0.0001\n    egg: 0.0001\n    ender_pearl: 0.0001\n\n# ########################\n# GAPPLES & POTIONS\n# ########################\n\nold-golden-apples:\n  # This is to change the behaviour / crafting of golden apples to how it was in pre-1.9\n  # WARNING: If on 1.12 or above and you disable this module you must reload the server for the recipe to disappear\n  # Cooldown between eating the apples, in seconds\n  cooldown:\n    # The cooldown for normal golden apples\n    # PlaceholderAPI: %ocm_gapple_cooldown%\n    normal: 0\n    # Message when user tries to eat golden apple during cooldown. Leave empty to disable.\n    message-normal: \"&ePlease wait %seconds%s before eating another golden apple.\"\n    # The cooldown for enchanted golden apples\n    # PlaceholderAPI: %ocm_napple_cooldown%\n    enchanted: 0\n    # Message when user tries to eat enchanted golden apple during cooldown. Leave empty to disable.\n    message-enchanted: \"&ePlease wait %seconds%s before eating another enchanted golden apple.\"\n    # Whether the two apple types share a cooldown.\n    # If this is true:\n    #   1. Eating any apple resets both cooldowns\n    #   2. Each apple type can only be eaten when its cooldown time is over\n    #      This means that when you eat *any* apple you start two parallel cooldowns: One for enchanted and one\n    #      for normal apples. Each type can only be eaten when its cooldown is over.\n    #      Once any apple is eaten, both cooldowns are restarted, so you can not eat either type again\n    #      before its full cooldown is over.\n    #   3. To have the plugin treat normal and enchanted golden apples as having the same cooldown,\n    #      then set the same cooldown time and enable shared mode. (This was the old mode)\n    # If this is false:\n    #   Eating an enchanted apple will prevent any *enchanted* apple type from being eaten before the cooldown is over\n    #   Eating a normal apple will prevent any *normal* apple type from being eaten before the normal cooldown is over\n    is-shared: false\n  # If you want to allow enchanted golden apple crafting\n  enchanted-golden-apple-crafting: true\n  # Enabling this makes the potion effects gained by eating golden apples\n  # and enchanted golden apples the same as it was in pre-1.9\n  old-potion-effects: true\n  # Potion effects for golden apples\n  # Duration is in seconds\n  # Amplifier is the potion level-1, so Regeneration IV would be amplifier 3\n  golden-apple-effects:\n    regeneration:\n      duration: 5\n      amplifier: 1\n    absorption:\n      duration: 120\n      amplifier: 0\n  # Potion effects for enchanted golden apples\n  enchanted-golden-apple-effects:\n    regeneration:\n      duration: 30\n      amplifier: 4\n    resistance:\n      duration: 300\n      amplifier: 0\n    fire_resistance:\n      duration: 300\n      amplifier: 0\n    absorption:\n      duration: 120\n      amplifier: 0\n  # Enable this if you have another plugin which adds a crafting recipe for\n  # enchanted golden apples (requires server restart)\n  no-conflict-mode: false\n\nold-potion-effects:\n  # This is to restore the 1.8 potion effects and duration\n\n  # DURATION: (in seconds)\n  potion-durations:\n    drinkable:\n      regeneration: 45\n      strong_regeneration: 22\n      long_regeneration: 120\n\n      swiftness: 180\n      strong_swiftness: 90\n      long_swiftness: 480\n\n      fire_resistance: 180\n      long_fire_resistance: 480\n\n      poison: 45\n      strong_poison: 22\n      long_poison: 120\n\n      night_vision: 180\n      long_night_vision: 480\n\n      weakness: 90\n      long_weakness: 240\n\n      strength: 180\n      strong_strength: 90\n      long_strength: 480\n\n      slowness: 90\n      long_slowness: 240\n\n      leaping: 180\n      strong_leaping: 90\n      long_leaping: 480\n\n      water_breathing: 180\n      long_water_breathing: 480\n\n      invisibility: 180\n      long_invisibility: 480\n\n      # 1.9+ potions. You can add more as needed.\n      # Make sure to also add to splash section below.\n      luck: 300\n\n      slow_falling: 90\n      long_slow_falling: 240\n\n    splash:\n      regeneration: 33\n      strong_regeneration: 16\n      long_regeneration: 90\n\n      swiftness: 135\n      strong_swiftness: 67\n      long_swiftness: 360\n\n      fire_resistance: 135\n      long_fire_resistance: 360\n\n      poison: 33\n      strong_poison: 16\n      long_poison: 90\n\n      night_vision: 180\n      long_night_vision: 480\n\n      weakness: 90\n      long_weakness: 240\n\n      strength: 135\n      strong_strength: 67\n      long_strength: 360\n\n      slowness: 67\n      long_slowness: 180\n\n      leaping: 135\n      strong_leaping: 67\n      long_leaping: 360\n\n      water_breathing: 135\n      long_water_breathing: 360\n\n      invisibility: 135\n      long_invisibility: 360\n\n      # 1.9+ potions. You can add more as needed\n      luck: 300\n\n      slow_falling: 90\n      long_slow_falling: 240\n\n  # EFFECTS\n  # If 'multiplier' is true value is multiplied by base tool damage. If 'addend' it is added.\n  # If both true, it is first increased by 1 then multiplied (same as +xx%)\n  # Strength potion\n  # 1.9: I = +3; II = +6;    1.8: I = +130%; II = +260%\n  strength:\n    modifier: 1.3\n    multiplier: true\n    addend: true\n  # Weakness potion\n  # 1.9 value: -4   1.8 value: -0.5\n  weakness:\n    modifier: -0.5\n    multiplier: false\n\n# ########################\n# MISCELLANEOUS\n# ########################\n\ndisable-crafting:\n  # Disable the crafting of specified items\n  # List of denied items\n  denied:\n    - shield\n  # Show the user a message if they try to craft a blacklisted item\n  showMessage: true\n  message: \"&cYou cannot craft that item!\"\n\ndisable-offhand:\n  # Disable the usage of the offhand\n  # Won't affect sword-blocking module\n  # Whether the following list allows items or blocks them\n  whitelist: true\n  # List of items that should be allowed/blocked\n  # Example: [diamond_sword,BOW]\n  items: [ ]\n  # Message to send user when denied. Set to '' to disable\n  denied-message: \"&cOff-hand is disabled\"\n\nold-brewing-stand:\n  # Automatically refuels brewing stands\n\ndisable-enderpearl-cooldown:\n  # Disables enderpearl cooldown\n  # The cooldown, in seconds\n  # PlaceholderAPI: %ocm_enderpearl_cooldown%\n  cooldown: 0\n  # Show the user a message if they try to use an enderpearl and the cooldown has not expired yet\n  showMessage: true\n  message: \"&cYou must wait &7%ds&c before using an enderpearl again!\"\n\nchorus-fruit:\n  # This makes the chorus fruit behaviour configurable\n  # The maximum distance the fruit can teleport the player. This a PER AXIS value, so this outlines a cube with\n  # 2 * max-teleportation-distance as the side length\n  # Vanilla default is 8.\n  # Setting this to 0 disables chorus fruit teleport.\n  # Setting this to a value greater than 8 MIGHT CAUSE CONFLICTS with bukkit's internal anti cheat\n  # and *potentially* any other anti-cheat you use. Please make sure this is not an issue before increasing\n  # this value.\n  max-teleportation-distance: 8\n  # Whether to prevent eating the fruit completely. This also prevents the teleportation.\n  prevent-eating: false\n  # The saturation value of the chorus fruit.\n  # Vanilla default is 2.4\n  saturation-value: 2.4\n  # The hunger value of the chorus fruit.\n  # Vanilla default is 4 (2 bars)\n  hunger-value: 4\n\nold-burn-delay:\n  # This makes it so entities will immediately start to burn when entering fire\n  # How long, in ticks, entities should be on fire for after not being in direct contact anymore\n  fire-ticks: 120\n\ndisable-attack-sounds:\n  # Disables attack sounds that were added with 1.9+\n  # Requires PacketEvents\n  # The sounds that will be blocked by this module\n  blocked-sound-names:\n    - \"ENTITY_PLAYER_ATTACK_STRONG\"\n    - \"ENTITY_PLAYER_ATTACK_SWEEP\"\n    - \"ENTITY_PLAYER_ATTACK_NODAMAGE\"\n    - \"ENTITY_PLAYER_ATTACK_KNOCKBACK\"\n    - \"ENTITY_PLAYER_ATTACK_CRIT\"\n    - \"ENTITY_PLAYER_ATTACK_WEAK\"\n\n# ########################\n# SPECIAL SETTINGS BELOW #\n# ########################\n\nmessage-prefix: \"&6[OCM]&r\"\n\n# This is to toggle the update checker\nupdate-checker:\n  # Whether to check for updates and notify players with the oldcombatmechanics.notify permission\n  # Whether to automatically download an update of the plugin\n  # The update is applied on the next restart/reload of the server\n  # Auto update is disabled if Spigot version is below 1.18.1 and force-below-1-18-1-config-upgrade is false\n  # This is to prevent accidentally resetting the config\n  auto-update: true\n\n# Whether to force config upgrade even in Spigot versions below 1.18.1\n# This is not advised, as all the comments would be removed\nforce-below-1-18-1-config-upgrade: false\n\n# This enables command argument completion when pressing tab\ncommand-completer:\n  enabled: true\n\n# This enables debug messages, only enable when troubleshooting\ndebug:\n  enabled: false\n\n# DO NOT CHANGE THIS NUMBER AS IT WILL RESET YOUR CONFIG\nconfig-version: 73\n"
  },
  {
    "path": "src/main/resources/plugin.yml",
    "content": "# This Source Code Form is subject to the terms of the Mozilla Public\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at https://mozilla.org/MPL/2.0/.\nmain: kernitus.plugin.OldCombatMechanics.OCMMain\nname: OldCombatMechanics\nversion: ${pluginVersion}\nauthors: [kernitus, Rayzr522]\ndescription: Reverts to pre-1.9 combat mechanics\nwebsite: https://github.com/kernitus/BukkitOldCombatMechanics\nload: POSTWORLD\nsoftdepend: [PlaceholderAPI]\napi-version: 1.13\n\ncommands:\n  oldcombatmechanics:\n   description: OldCombatMechanics's main command\n   aliases: [ocm]\n   permission: oldcombatmechanics.commands\n   \npermissions:\n  oldcombatmechanics.*:\n    description: Gives access to all OCM permissions\n    default: op\n    children:\n      oldcombatmechanics.commands: true\n      oldcombatmechanics.notify: true\n      oldcombatmechanics.mode: true\n      oldcombatmechanics.mode.own: true\n      oldcombatmechanics.mode.others: true\n      oldcombatmechanics.swordblock: true\n  oldcombatmechanics.commands:\n    description: Allows the usage of OCM commands\n    default: op\n  oldcombatmechanics.notify:\n    description: Notifies of new OCM updates\n    default: op\n  oldcombatmechanics.mode:\n    description: Allows player to use /ocm mode to check own modeset\n    default: op\n  oldcombatmechanics.mode.own:\n    description: Allows player to use /ocm mode to change own modeset\n    default: op\n    children:\n      oldcombatmechanics.mode: true\n  oldcombatmechanics.mode.others:\n    description: Allows player to use /ocm mode to change other players' modeset\n    default: op\n    children:\n      oldcombatmechanics.mode: true\n  oldcombatmechanics.swordblock:\n    description: Allows players to block with sword\n    default: op\n"
  }
]