Repository: kernitus/BukkitOldCombatMechanics
Branch: master
Commit: 7edb0a525dc4
Files: 129
Total size: 1.2 MB
Directory structure:
gitextract_5hk29j6x/
├── .github/
│ ├── CONTRIBUTING.md
│ ├── ISSUE_TEMPLATE/
│ │ ├── 1-bug-report.yaml
│ │ ├── 2-question.yaml
│ │ ├── 3-feature.yaml
│ │ └── config.yml
│ ├── release-please-config.json
│ ├── release-please-manifest.json
│ └── workflows/
│ ├── build-upload-release.yml
│ ├── dev-builds.yml
│ ├── release-please.yml
│ └── wrap-issue-form-codeblocks.yml
├── .gitignore
├── AGENTS.md
├── CHANGELOG.md
├── LICENCE
├── README.md
├── build.gradle.kts
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src/
├── integrationTest/
│ ├── kotlin/
│ │ └── kernitus/
│ │ └── plugin/
│ │ └── OldCombatMechanics/
│ │ ├── AttackCompat.kt
│ │ ├── AttackCooldownHeldItemIntegrationTest.kt
│ │ ├── AttackCooldownTrackerIntegrationTest.kt
│ │ ├── AttackRangeIntegrationTest.kt
│ │ ├── AttributeModifierCompat.kt
│ │ ├── ChorusFruitIntegrationTest.kt
│ │ ├── ConfigMigrationIntegrationTest.kt
│ │ ├── ConsumableComponentIntegrationTest.kt
│ │ ├── CopperToolsIntegrationTest.kt
│ │ ├── CustomWeaponDamageIntegrationTest.kt
│ │ ├── DisableOffhandIntegrationTest.kt
│ │ ├── DisableOffhandReflectionIntegrationTest.kt
│ │ ├── EnderpearlCooldownIntegrationTest.kt
│ │ ├── FakePlayer.kt
│ │ ├── FireAspectOverdamageIntegrationTest.kt
│ │ ├── FishingRodVelocityIntegrationTest.kt
│ │ ├── GoldenAppleIntegrationTest.kt
│ │ ├── InGameTester.kt
│ │ ├── InGameTesterIntegrationTest.kt
│ │ ├── InvulnerabilityDamageIntegrationTest.kt
│ │ ├── KotestRunner.kt
│ │ ├── LegacyFakePlayer12.kt
│ │ ├── LegacyFakePlayer9.kt
│ │ ├── ModesetRulesIntegrationTest.kt
│ │ ├── OCMTest.kt
│ │ ├── OCMTestMain.kt
│ │ ├── OldArmourDurabilityIntegrationTest.kt
│ │ ├── OldCriticalHitsIntegrationTest.kt
│ │ ├── OldPotionEffectsIntegrationTest.kt
│ │ ├── OldToolDamageMobIntegrationTest.kt
│ │ ├── PacketCancellationIntegrationTest.kt
│ │ ├── PaperSwordBlockingDamageReductionIntegrationTest.kt
│ │ ├── PlayerKnockbackIntegrationTest.kt
│ │ ├── PlayerRegenIntegrationTest.kt
│ │ ├── SpigotFunctionChooserIntegrationTest.kt
│ │ ├── SwordBlockingIntegrationTest.kt
│ │ ├── SwordSweepIntegrationTest.kt
│ │ ├── Tally.kt
│ │ ├── TestResultWriter.kt
│ │ ├── TesterUtils.kt
│ │ ├── ToolDamageTooltipIntegrationTest.kt
│ │ └── WeaponDurabilityIntegrationTest.kt
│ └── resources/
│ └── plugin.yml
└── main/
├── java/
│ └── kernitus/
│ └── plugin/
│ └── OldCombatMechanics/
│ ├── ModuleLoader.java
│ ├── OCMConfigHandler.java
│ ├── OCMMain.java
│ ├── UpdateChecker.java
│ ├── commands/
│ │ ├── OCMCommandCompleter.java
│ │ └── OCMCommandHandler.java
│ ├── hooks/
│ │ ├── PlaceholderAPIHook.java
│ │ └── api/
│ │ └── Hook.java
│ ├── module/
│ │ ├── ModuleAttackCooldown.java
│ │ ├── ModuleAttackFrequency.java
│ │ ├── ModuleAttackRange.java
│ │ ├── ModuleAttackSounds.java
│ │ ├── ModuleChorusFruit.java
│ │ ├── ModuleDisableCrafting.java
│ │ ├── ModuleDisableEnderpearlCooldown.java
│ │ ├── ModuleDisableOffHand.java
│ │ ├── ModuleFishingKnockback.java
│ │ ├── ModuleFishingRodVelocity.java
│ │ ├── ModuleGoldenApple.java
│ │ ├── ModuleOldArmourDurability.java
│ │ ├── ModuleOldArmourStrength.java
│ │ ├── ModuleOldBrewingStand.java
│ │ ├── ModuleOldBurnDelay.java
│ │ ├── ModuleOldCriticalHits.java
│ │ ├── ModuleOldPotionEffects.java
│ │ ├── ModuleOldToolDamage.java
│ │ ├── ModulePlayerKnockback.java
│ │ ├── ModulePlayerRegen.java
│ │ ├── ModuleProjectileKnockback.java
│ │ ├── ModuleShieldDamageReduction.java
│ │ ├── ModuleSwordBlocking.java
│ │ ├── ModuleSwordSweep.java
│ │ ├── ModuleSwordSweepParticles.java
│ │ └── OCMModule.java
│ ├── paper/
│ │ └── PaperSwordBlocking.java
│ ├── updater/
│ │ ├── ModuleUpdateChecker.java
│ │ ├── SpigetUpdateChecker.java
│ │ └── VersionChecker.java
│ └── utilities/
│ ├── Config.java
│ ├── ConfigUtils.java
│ ├── EventRegistry.java
│ ├── MathsHelper.java
│ ├── Messenger.java
│ ├── TextUtils.java
│ ├── damage/
│ │ ├── AttackCooldownTracker.java
│ │ ├── DamageUtils.java
│ │ ├── DefenceUtils.java
│ │ ├── EntityDamageByEntityListener.java
│ │ ├── MobDamage.java
│ │ ├── NewWeaponDamage.java
│ │ ├── OCMEntityDamageByEntityEvent.java
│ │ └── WeaponDamages.java
│ ├── potions/
│ │ ├── PotionDurations.java
│ │ ├── PotionEffects.java
│ │ ├── PotionKey.java
│ │ └── WeaknessCompensation.java
│ ├── reflection/
│ │ ├── Reflector.java
│ │ ├── SpigotFunctionChooser.java
│ │ └── VersionCompatUtils.java
│ └── storage/
│ ├── ModesetListener.java
│ ├── PlayerData.java
│ ├── PlayerDataCodec.java
│ └── PlayerStorage.java
└── resources/
├── config.yml
└── plugin.yml
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing
All 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*.
## Testing expectations
Contributions 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.
## Language expectations
New 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.
## Module system
OldCombatMechanics 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.
## Module naming
The 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.
## Module configuration
All 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.
## Code style
There is relative freedom when it codes to coding style, but please adhere to the following guidelines:
* Use `final` for variables whenever possible. This makes it much less likely to accidentally change a variable and improves code readability.
* 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.
* 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.
## Formatting profiles
Please 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:
```
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
tab_width = 4
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = false
ij_smart_tabs = false
ij_visual_guides = none
ij_wrap_on_typing = false
[*.css]
ij_css_align_closing_brace_with_properties = false
ij_css_blank_lines_around_nested_selector = 1
ij_css_blank_lines_between_blocks = 1
ij_css_brace_placement = end_of_line
ij_css_enforce_quotes_on_format = false
ij_css_hex_color_long_format = false
ij_css_hex_color_lower_case = false
ij_css_hex_color_short_format = false
ij_css_hex_color_upper_case = false
ij_css_keep_blank_lines_in_code = 2
ij_css_keep_indents_on_empty_lines = false
ij_css_keep_single_line_blocks = false
ij_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
ij_css_space_after_colon = true
ij_css_space_before_opening_brace = true
ij_css_use_double_quotes = true
ij_css_value_alignment = do_not_align
[*.feature]
indent_size = 2
ij_gherkin_keep_indents_on_empty_lines = false
[*.gsp]
ij_gsp_keep_indents_on_empty_lines = false
[*.haml]
indent_size = 2
ij_haml_keep_indents_on_empty_lines = false
[*.java]
ij_java_align_consecutive_assignments = false
ij_java_align_consecutive_variable_declarations = false
ij_java_align_group_field_declarations = false
ij_java_align_multiline_annotation_parameters = false
ij_java_align_multiline_array_initializer_expression = false
ij_java_align_multiline_assignment = false
ij_java_align_multiline_binary_operation = false
ij_java_align_multiline_chained_methods = false
ij_java_align_multiline_extends_list = false
ij_java_align_multiline_for = true
ij_java_align_multiline_method_parentheses = false
ij_java_align_multiline_parameters = true
ij_java_align_multiline_parameters_in_calls = false
ij_java_align_multiline_parenthesized_expression = false
ij_java_align_multiline_records = true
ij_java_align_multiline_resources = true
ij_java_align_multiline_ternary_operation = false
ij_java_align_multiline_text_blocks = false
ij_java_align_multiline_throws_list = false
ij_java_align_subsequent_simple_methods = false
ij_java_align_throws_keyword = false
ij_java_annotation_parameter_wrap = off
ij_java_array_initializer_new_line_after_left_brace = false
ij_java_array_initializer_right_brace_on_new_line = false
ij_java_array_initializer_wrap = off
ij_java_assert_statement_colon_on_next_line = false
ij_java_assert_statement_wrap = off
ij_java_assignment_wrap = off
ij_java_binary_operation_sign_on_next_line = false
ij_java_binary_operation_wrap = off
ij_java_blank_lines_after_anonymous_class_header = 0
ij_java_blank_lines_after_class_header = 0
ij_java_blank_lines_after_imports = 1
ij_java_blank_lines_after_package = 1
ij_java_blank_lines_around_class = 1
ij_java_blank_lines_around_field = 0
ij_java_blank_lines_around_field_in_interface = 0
ij_java_blank_lines_around_initializer = 1
ij_java_blank_lines_around_method = 1
ij_java_blank_lines_around_method_in_interface = 1
ij_java_blank_lines_before_class_end = 0
ij_java_blank_lines_before_imports = 1
ij_java_blank_lines_before_method_body = 0
ij_java_blank_lines_before_package = 0
ij_java_block_brace_style = end_of_line
ij_java_block_comment_at_first_column = true
ij_java_call_parameters_new_line_after_left_paren = false
ij_java_call_parameters_right_paren_on_new_line = false
ij_java_call_parameters_wrap = off
ij_java_case_statement_on_separate_line = true
ij_java_catch_on_new_line = false
ij_java_class_annotation_wrap = split_into_lines
ij_java_class_brace_style = end_of_line
ij_java_class_count_to_use_import_on_demand = 5
ij_java_class_names_in_javadoc = 1
ij_java_do_not_indent_top_level_class_members = false
ij_java_do_not_wrap_after_single_annotation = false
ij_java_do_while_brace_force = never
ij_java_doc_add_blank_line_after_description = true
ij_java_doc_add_blank_line_after_param_comments = false
ij_java_doc_add_blank_line_after_return = false
ij_java_doc_add_p_tag_on_empty_lines = true
ij_java_doc_align_exception_comments = true
ij_java_doc_align_param_comments = true
ij_java_doc_do_not_wrap_if_one_line = false
ij_java_doc_enable_formatting = true
ij_java_doc_enable_leading_asterisks = true
ij_java_doc_indent_on_continuation = false
ij_java_doc_keep_empty_lines = true
ij_java_doc_keep_empty_parameter_tag = true
ij_java_doc_keep_empty_return_tag = true
ij_java_doc_keep_empty_throws_tag = true
ij_java_doc_keep_invalid_tags = true
ij_java_doc_param_description_on_new_line = false
ij_java_doc_preserve_line_breaks = false
ij_java_doc_use_throws_not_exception_tag = true
ij_java_else_on_new_line = false
ij_java_entity_dd_suffix = EJB
ij_java_entity_eb_suffix = Bean
ij_java_entity_hi_suffix = Home
ij_java_entity_lhi_prefix = Local
ij_java_entity_lhi_suffix = Home
ij_java_entity_li_prefix = Local
ij_java_entity_pk_class = java.lang.String
ij_java_entity_vo_suffix = VO
ij_java_enum_constants_wrap = off
ij_java_extends_keyword_wrap = off
ij_java_extends_list_wrap = off
ij_java_field_annotation_wrap = split_into_lines
ij_java_finally_on_new_line = false
ij_java_for_brace_force = never
ij_java_for_statement_new_line_after_left_paren = false
ij_java_for_statement_right_paren_on_new_line = false
ij_java_for_statement_wrap = off
ij_java_generate_final_locals = false
ij_java_generate_final_parameters = false
ij_java_if_brace_force = never
ij_java_imports_layout = *,|,javax.**,java.**,|,$*
ij_java_indent_case_from_switch = true
ij_java_insert_inner_class_imports = false
ij_java_insert_override_annotation = true
ij_java_keep_blank_lines_before_right_brace = 2
ij_java_keep_blank_lines_between_package_declaration_and_header = 2
ij_java_keep_blank_lines_in_code = 2
ij_java_keep_blank_lines_in_declarations = 2
ij_java_keep_control_statement_in_one_line = true
ij_java_keep_first_column_comment = true
ij_java_keep_indents_on_empty_lines = false
ij_java_keep_line_breaks = true
ij_java_keep_multiple_expressions_in_one_line = false
ij_java_keep_simple_blocks_in_one_line = false
ij_java_keep_simple_classes_in_one_line = false
ij_java_keep_simple_lambdas_in_one_line = false
ij_java_keep_simple_methods_in_one_line = false
ij_java_label_indent_absolute = false
ij_java_label_indent_size = 0
ij_java_lambda_brace_style = end_of_line
ij_java_layout_static_imports_separately = true
ij_java_line_comment_add_space = false
ij_java_line_comment_at_first_column = true
ij_java_message_dd_suffix = EJB
ij_java_message_eb_suffix = Bean
ij_java_method_annotation_wrap = split_into_lines
ij_java_method_brace_style = end_of_line
ij_java_method_call_chain_wrap = off
ij_java_method_parameters_new_line_after_left_paren = false
ij_java_method_parameters_right_paren_on_new_line = false
ij_java_method_parameters_wrap = off
ij_java_modifier_list_wrap = false
ij_java_names_count_to_use_import_on_demand = 3
ij_java_new_line_after_lparen_in_record_header = false
ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.*
ij_java_parameter_annotation_wrap = off
ij_java_parentheses_expression_new_line_after_left_paren = false
ij_java_parentheses_expression_right_paren_on_new_line = false
ij_java_place_assignment_sign_on_next_line = false
ij_java_prefer_longer_names = true
ij_java_prefer_parameters_wrap = false
ij_java_record_components_wrap = normal
ij_java_repeat_synchronized = true
ij_java_replace_instanceof_and_cast = false
ij_java_replace_null_check = true
ij_java_replace_sum_lambda_with_method_ref = true
ij_java_resource_list_new_line_after_left_paren = false
ij_java_resource_list_right_paren_on_new_line = false
ij_java_resource_list_wrap = off
ij_java_rparen_on_new_line_in_record_header = false
ij_java_session_dd_suffix = EJB
ij_java_session_eb_suffix = Bean
ij_java_session_hi_suffix = Home
ij_java_session_lhi_prefix = Local
ij_java_session_lhi_suffix = Home
ij_java_session_li_prefix = Local
ij_java_session_si_suffix = Service
ij_java_space_after_closing_angle_bracket_in_type_argument = false
ij_java_space_after_colon = true
ij_java_space_after_comma = true
ij_java_space_after_comma_in_type_arguments = true
ij_java_space_after_for_semicolon = true
ij_java_space_after_quest = true
ij_java_space_after_type_cast = true
ij_java_space_before_annotation_array_initializer_left_brace = false
ij_java_space_before_annotation_parameter_list = false
ij_java_space_before_array_initializer_left_brace = false
ij_java_space_before_catch_keyword = true
ij_java_space_before_catch_left_brace = true
ij_java_space_before_catch_parentheses = true
ij_java_space_before_class_left_brace = true
ij_java_space_before_colon = true
ij_java_space_before_colon_in_foreach = true
ij_java_space_before_comma = false
ij_java_space_before_do_left_brace = true
ij_java_space_before_else_keyword = true
ij_java_space_before_else_left_brace = true
ij_java_space_before_finally_keyword = true
ij_java_space_before_finally_left_brace = true
ij_java_space_before_for_left_brace = true
ij_java_space_before_for_parentheses = true
ij_java_space_before_for_semicolon = false
ij_java_space_before_if_left_brace = true
ij_java_space_before_if_parentheses = true
ij_java_space_before_method_call_parentheses = false
ij_java_space_before_method_left_brace = true
ij_java_space_before_method_parentheses = false
ij_java_space_before_opening_angle_bracket_in_type_parameter = false
ij_java_space_before_quest = true
ij_java_space_before_switch_left_brace = true
ij_java_space_before_switch_parentheses = true
ij_java_space_before_synchronized_left_brace = true
ij_java_space_before_synchronized_parentheses = true
ij_java_space_before_try_left_brace = true
ij_java_space_before_try_parentheses = true
ij_java_space_before_type_parameter_list = false
ij_java_space_before_while_keyword = true
ij_java_space_before_while_left_brace = true
ij_java_space_before_while_parentheses = true
ij_java_space_inside_one_line_enum_braces = false
ij_java_space_within_empty_array_initializer_braces = false
ij_java_space_within_empty_method_call_parentheses = false
ij_java_space_within_empty_method_parentheses = false
ij_java_spaces_around_additive_operators = true
ij_java_spaces_around_assignment_operators = true
ij_java_spaces_around_bitwise_operators = true
ij_java_spaces_around_equality_operators = true
ij_java_spaces_around_lambda_arrow = true
ij_java_spaces_around_logical_operators = true
ij_java_spaces_around_method_ref_dbl_colon = false
ij_java_spaces_around_multiplicative_operators = true
ij_java_spaces_around_relational_operators = true
ij_java_spaces_around_shift_operators = true
ij_java_spaces_around_type_bounds_in_type_parameters = true
ij_java_spaces_around_unary_operator = false
ij_java_spaces_within_angle_brackets = false
ij_java_spaces_within_annotation_parentheses = false
ij_java_spaces_within_array_initializer_braces = false
ij_java_spaces_within_braces = false
ij_java_spaces_within_brackets = false
ij_java_spaces_within_cast_parentheses = false
ij_java_spaces_within_catch_parentheses = false
ij_java_spaces_within_for_parentheses = false
ij_java_spaces_within_if_parentheses = false
ij_java_spaces_within_method_call_parentheses = false
ij_java_spaces_within_method_parentheses = false
ij_java_spaces_within_parentheses = false
ij_java_spaces_within_record_header = false
ij_java_spaces_within_switch_parentheses = false
ij_java_spaces_within_synchronized_parentheses = false
ij_java_spaces_within_try_parentheses = false
ij_java_spaces_within_while_parentheses = false
ij_java_special_else_if_treatment = true
ij_java_subclass_name_suffix = Impl
ij_java_ternary_operation_signs_on_next_line = false
ij_java_ternary_operation_wrap = off
ij_java_test_name_suffix = Test
ij_java_throws_keyword_wrap = off
ij_java_throws_list_wrap = off
ij_java_use_external_annotations = false
ij_java_use_fq_class_names = false
ij_java_use_relative_indents = false
ij_java_use_single_class_imports = true
ij_java_variable_annotation_wrap = off
ij_java_visibility = public
ij_java_while_brace_force = never
ij_java_while_on_new_line = false
ij_java_wrap_comments = false
ij_java_wrap_first_method_in_call_chain = false
ij_java_wrap_long_lines = false
[.editorconfig]
ij_editorconfig_align_group_field_declarations = false
ij_editorconfig_space_after_colon = false
ij_editorconfig_space_after_comma = true
ij_editorconfig_space_before_colon = false
ij_editorconfig_space_before_comma = false
ij_editorconfig_spaces_around_assignment_operators = true
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_xml_align_attributes = true
ij_xml_align_text = false
ij_xml_attribute_wrap = normal
ij_xml_block_comment_at_first_column = true
ij_xml_keep_blank_lines = 2
ij_xml_keep_indents_on_empty_lines = false
ij_xml_keep_line_breaks = true
ij_xml_keep_line_breaks_in_text = true
ij_xml_keep_whitespaces = false
ij_xml_keep_whitespaces_around_cdata = preserve
ij_xml_keep_whitespaces_inside_cdata = false
ij_xml_line_comment_at_first_column = true
ij_xml_space_after_tag_name = false
ij_xml_space_around_equals_in_attribute = false
ij_xml_space_inside_empty_tag = false
ij_xml_text_wrap = normal
ij_xml_use_custom_settings = false
[{*.ft,*.vm,*.vsl}]
ij_vtl_keep_indents_on_empty_lines = false
[{*.gant,*.gradle,*.groovy,*.gson,*.gy}]
ij_groovy_align_group_field_declarations = false
ij_groovy_align_multiline_array_initializer_expression = false
ij_groovy_align_multiline_assignment = false
ij_groovy_align_multiline_binary_operation = false
ij_groovy_align_multiline_chained_methods = false
ij_groovy_align_multiline_extends_list = false
ij_groovy_align_multiline_for = true
ij_groovy_align_multiline_list_or_map = true
ij_groovy_align_multiline_method_parentheses = false
ij_groovy_align_multiline_parameters = true
ij_groovy_align_multiline_parameters_in_calls = false
ij_groovy_align_multiline_resources = true
ij_groovy_align_multiline_ternary_operation = false
ij_groovy_align_multiline_throws_list = false
ij_groovy_align_named_args_in_map = true
ij_groovy_align_throws_keyword = false
ij_groovy_array_initializer_new_line_after_left_brace = false
ij_groovy_array_initializer_right_brace_on_new_line = false
ij_groovy_array_initializer_wrap = off
ij_groovy_assert_statement_wrap = off
ij_groovy_assignment_wrap = off
ij_groovy_binary_operation_wrap = off
ij_groovy_blank_lines_after_class_header = 0
ij_groovy_blank_lines_after_imports = 1
ij_groovy_blank_lines_after_package = 1
ij_groovy_blank_lines_around_class = 1
ij_groovy_blank_lines_around_field = 0
ij_groovy_blank_lines_around_field_in_interface = 0
ij_groovy_blank_lines_around_method = 1
ij_groovy_blank_lines_around_method_in_interface = 1
ij_groovy_blank_lines_before_imports = 1
ij_groovy_blank_lines_before_method_body = 0
ij_groovy_blank_lines_before_package = 0
ij_groovy_block_brace_style = end_of_line
ij_groovy_block_comment_at_first_column = true
ij_groovy_call_parameters_new_line_after_left_paren = false
ij_groovy_call_parameters_right_paren_on_new_line = false
ij_groovy_call_parameters_wrap = off
ij_groovy_catch_on_new_line = false
ij_groovy_class_annotation_wrap = split_into_lines
ij_groovy_class_brace_style = end_of_line
ij_groovy_class_count_to_use_import_on_demand = 5
ij_groovy_do_while_brace_force = never
ij_groovy_else_on_new_line = false
ij_groovy_enum_constants_wrap = off
ij_groovy_extends_keyword_wrap = off
ij_groovy_extends_list_wrap = off
ij_groovy_field_annotation_wrap = split_into_lines
ij_groovy_finally_on_new_line = false
ij_groovy_for_brace_force = never
ij_groovy_for_statement_new_line_after_left_paren = false
ij_groovy_for_statement_right_paren_on_new_line = false
ij_groovy_for_statement_wrap = off
ij_groovy_if_brace_force = never
ij_groovy_import_annotation_wrap = 2
ij_groovy_imports_layout = *,|,javax.**,java.**,|,$*
ij_groovy_indent_case_from_switch = true
ij_groovy_indent_label_blocks = true
ij_groovy_insert_inner_class_imports = false
ij_groovy_keep_blank_lines_before_right_brace = 2
ij_groovy_keep_blank_lines_in_code = 2
ij_groovy_keep_blank_lines_in_declarations = 2
ij_groovy_keep_control_statement_in_one_line = true
ij_groovy_keep_first_column_comment = true
ij_groovy_keep_indents_on_empty_lines = false
ij_groovy_keep_line_breaks = true
ij_groovy_keep_multiple_expressions_in_one_line = false
ij_groovy_keep_simple_blocks_in_one_line = false
ij_groovy_keep_simple_classes_in_one_line = true
ij_groovy_keep_simple_lambdas_in_one_line = true
ij_groovy_keep_simple_methods_in_one_line = true
ij_groovy_label_indent_absolute = false
ij_groovy_label_indent_size = 0
ij_groovy_lambda_brace_style = end_of_line
ij_groovy_layout_static_imports_separately = true
ij_groovy_line_comment_add_space = false
ij_groovy_line_comment_at_first_column = true
ij_groovy_method_annotation_wrap = split_into_lines
ij_groovy_method_brace_style = end_of_line
ij_groovy_method_call_chain_wrap = off
ij_groovy_method_parameters_new_line_after_left_paren = false
ij_groovy_method_parameters_right_paren_on_new_line = false
ij_groovy_method_parameters_wrap = off
ij_groovy_modifier_list_wrap = false
ij_groovy_names_count_to_use_import_on_demand = 3
ij_groovy_parameter_annotation_wrap = off
ij_groovy_parentheses_expression_new_line_after_left_paren = false
ij_groovy_parentheses_expression_right_paren_on_new_line = false
ij_groovy_prefer_parameters_wrap = false
ij_groovy_resource_list_new_line_after_left_paren = false
ij_groovy_resource_list_right_paren_on_new_line = false
ij_groovy_resource_list_wrap = off
ij_groovy_space_after_assert_separator = true
ij_groovy_space_after_colon = true
ij_groovy_space_after_comma = true
ij_groovy_space_after_comma_in_type_arguments = true
ij_groovy_space_after_for_semicolon = true
ij_groovy_space_after_quest = true
ij_groovy_space_after_type_cast = true
ij_groovy_space_before_annotation_parameter_list = false
ij_groovy_space_before_array_initializer_left_brace = false
ij_groovy_space_before_assert_separator = false
ij_groovy_space_before_catch_keyword = true
ij_groovy_space_before_catch_left_brace = true
ij_groovy_space_before_catch_parentheses = true
ij_groovy_space_before_class_left_brace = true
ij_groovy_space_before_closure_left_brace = true
ij_groovy_space_before_colon = true
ij_groovy_space_before_comma = false
ij_groovy_space_before_do_left_brace = true
ij_groovy_space_before_else_keyword = true
ij_groovy_space_before_else_left_brace = true
ij_groovy_space_before_finally_keyword = true
ij_groovy_space_before_finally_left_brace = true
ij_groovy_space_before_for_left_brace = true
ij_groovy_space_before_for_parentheses = true
ij_groovy_space_before_for_semicolon = false
ij_groovy_space_before_if_left_brace = true
ij_groovy_space_before_if_parentheses = true
ij_groovy_space_before_method_call_parentheses = false
ij_groovy_space_before_method_left_brace = true
ij_groovy_space_before_method_parentheses = false
ij_groovy_space_before_quest = true
ij_groovy_space_before_switch_left_brace = true
ij_groovy_space_before_switch_parentheses = true
ij_groovy_space_before_synchronized_left_brace = true
ij_groovy_space_before_synchronized_parentheses = true
ij_groovy_space_before_try_left_brace = true
ij_groovy_space_before_try_parentheses = true
ij_groovy_space_before_while_keyword = true
ij_groovy_space_before_while_left_brace = true
ij_groovy_space_before_while_parentheses = true
ij_groovy_space_in_named_argument = true
ij_groovy_space_in_named_argument_before_colon = false
ij_groovy_space_within_empty_array_initializer_braces = false
ij_groovy_space_within_empty_method_call_parentheses = false
ij_groovy_spaces_around_additive_operators = true
ij_groovy_spaces_around_assignment_operators = true
ij_groovy_spaces_around_bitwise_operators = true
ij_groovy_spaces_around_equality_operators = true
ij_groovy_spaces_around_lambda_arrow = true
ij_groovy_spaces_around_logical_operators = true
ij_groovy_spaces_around_multiplicative_operators = true
ij_groovy_spaces_around_regex_operators = true
ij_groovy_spaces_around_relational_operators = true
ij_groovy_spaces_around_shift_operators = true
ij_groovy_spaces_within_annotation_parentheses = false
ij_groovy_spaces_within_array_initializer_braces = false
ij_groovy_spaces_within_braces = true
ij_groovy_spaces_within_brackets = false
ij_groovy_spaces_within_cast_parentheses = false
ij_groovy_spaces_within_catch_parentheses = false
ij_groovy_spaces_within_for_parentheses = false
ij_groovy_spaces_within_gstring_injection_braces = false
ij_groovy_spaces_within_if_parentheses = false
ij_groovy_spaces_within_list_or_map = false
ij_groovy_spaces_within_method_call_parentheses = false
ij_groovy_spaces_within_method_parentheses = false
ij_groovy_spaces_within_parentheses = false
ij_groovy_spaces_within_switch_parentheses = false
ij_groovy_spaces_within_synchronized_parentheses = false
ij_groovy_spaces_within_try_parentheses = false
ij_groovy_spaces_within_tuple_expression = false
ij_groovy_spaces_within_while_parentheses = false
ij_groovy_special_else_if_treatment = true
ij_groovy_ternary_operation_wrap = off
ij_groovy_throws_keyword_wrap = off
ij_groovy_throws_list_wrap = off
ij_groovy_use_flying_geese_braces = false
ij_groovy_use_fq_class_names = false
ij_groovy_use_fq_class_names_in_javadoc = true
ij_groovy_use_relative_indents = false
ij_groovy_use_single_class_imports = true
ij_groovy_variable_annotation_wrap = off
ij_groovy_while_brace_force = never
ij_groovy_while_on_new_line = false
ij_groovy_wrap_long_lines = false
[{*.gradle.kts,*.kt,*.kts,*.main.kts}]
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_allow_trailing_comma = false
ij_kotlin_allow_trailing_comma_on_call_site = false
ij_kotlin_assignment_wrap = normal
ij_kotlin_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = true
ij_kotlin_call_parameters_right_paren_on_new_line = true
ij_kotlin_call_parameters_wrap = on_every_item
ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ij_kotlin_continuation_indent_for_chained_calls = false
ij_kotlin_continuation_indent_for_expression_bodies = false
ij_kotlin_continuation_indent_in_argument_lists = false
ij_kotlin_continuation_indent_in_elvis = false
ij_kotlin_continuation_indent_in_if_conditions = false
ij_kotlin_continuation_indent_in_parameter_lists = false
ij_kotlin_continuation_indent_in_supertype_lists = false
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = normal
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = true
ij_kotlin_import_nested_classes = false
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = true
ij_kotlin_method_parameters_right_paren_on_new_line = true
ij_kotlin_method_parameters_wrap = on_every_item
ij_kotlin_name_count_to_use_star_import = 5
ij_kotlin_name_count_to_use_star_import_for_members = 3
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false
[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
ij_html_align_attributes = true
ij_html_align_text = false
ij_html_attribute_wrap = normal
ij_html_block_comment_at_first_column = true
ij_html_do_not_align_children_of_min_lines = 0
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
ij_html_enforce_quotes = false
ij_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
ij_html_keep_blank_lines = 2
ij_html_keep_indents_on_empty_lines = false
ij_html_keep_line_breaks = true
ij_html_keep_line_breaks_in_text = true
ij_html_keep_whitespaces = false
ij_html_keep_whitespaces_inside = span,pre,textarea
ij_html_line_comment_at_first_column = true
ij_html_new_line_after_last_attribute = never
ij_html_new_line_before_first_attribute = never
ij_html_quote_style = double
ij_html_remove_new_line_before_tags = br
ij_html_space_after_tag_name = false
ij_html_space_around_equality_in_attribute = false
ij_html_space_inside_empty_tag = false
ij_html_text_wrap = normal
ij_html_uniform_ident = false
[{*.markdown,*.md}]
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
ij_markdown_force_one_space_between_words = true
ij_markdown_keep_indents_on_empty_lines = false
ij_markdown_max_lines_around_block_elements = 1
ij_markdown_max_lines_around_header = 1
ij_markdown_max_lines_between_paragraphs = 1
ij_markdown_min_lines_around_block_elements = 1
ij_markdown_min_lines_around_header = 1
ij_markdown_min_lines_between_paragraphs = 1
[{*.properties,spring.handlers,spring.schemas}]
ij_properties_align_group_field_declarations = false
ij_properties_keep_blank_lines = false
ij_properties_key_value_delimiter = equals
ij_properties_spaces_around_key_value_delimiter = false
[{*.yaml,*.yml}]
indent_size = 2
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
ij_yaml_space_before_colon = true
ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true
```
================================================
FILE: .github/ISSUE_TEMPLATE/1-bug-report.yaml
================================================
name: Bug Report
description: Report a bug in the plugin
labels: ["bug", "investigate"]
body:
- type: markdown
attributes:
value: |
## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️
1. Try the [latest test version](https://hangar.papermc.io/kernitus/OldCombatMechanics/versions?channel=Snapshot&platform=PAPER) first
2. Complete ALL fields below - incomplete reports will be CLOSED
3. This is a volunteer project - we have no obligation to help incomplete reports
4. Have a question? Please use the Question template instead
- type: input
id: server-version
attributes:
label: Server Version
description: Version of the server, e.g. Spigot 1.14.1 or Paper 1.19.3
placeholder: e.g. Paper 1.20.4
validations:
required: true
- type: input
id: ocm-version
attributes:
label: OldCombatMechanics Version
description: "EXACT version of OldCombatMechanics, e.g. 1.7.2 or 2.1.1-beta+e2f0369. DO NOT write 'latest' - versions change often."
placeholder: e.g. 2.1.1-beta+e2f0369
validations:
required: true
- type: textarea
id: server-log
attributes:
label: Server Log File
description: Console log from the server. DO NOT use an external service like pastebin, as these expire.
placeholder: Paste your log here
render: console
validations:
required: true
- type: textarea
id: config
attributes:
label: OldCombatMechanics config.yml
description: Your config file. DO NOT use an external service like pastebin, as these expire.
placeholder: Paste your config.yml here
render: yaml
validations:
required: true
- type: textarea
id: other-plugins
attributes:
label: Other Plugins
description: List of other plugins installed (conflicts are common)
placeholder: |
- ViaVersion 1.0.0
- AnotherPlugin 2.3.1
validations:
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: What you would do in order for the problem to occur
placeholder: |
1. Hit a mob with a stick
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behaviour
description: What do you think should happen when you perform the above steps?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behaviour
description: What does happen when you perform the above steps?
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, screenshots, or videos here
- type: checkboxes
id: confirmation
attributes:
label: Pre-submission checklist
options:
- label: I have tried the latest test/snapshot version
required: true
- label: I have filled out all required fields below
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/2-question.yaml
================================================
name: Question
description: Ask a question about the plugin
labels: ["question"]
body:
- type: markdown
attributes:
value: |
## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️
1. Check the readme and wiki first: https://github.com/kernitus/BukkitOldCombatMechanics/wiki
2. Search existing issues to see if your question has been answered
3. This is a volunteer project - we have no obligation to help incomplete reports
4. If you've found a bug, use the Bug Report template instead
5. If you want a new feature, use the Feature Request template instead
- type: checkboxes
id: confirmation
attributes:
label: Pre-submission checklist
options:
- label: I have checked the wiki and readme
required: true
- label: I have searched existing issues
required: true
- type: textarea
id: question
attributes:
label: Your Question
description: Clearly describe what you want to know
validations:
required: true
- type: textarea
id: tried
attributes:
label: What I've Tried
description: Have you checked documentation? Tried anything? Searched for similar issues?
validations:
required: true
- type: input
id: server-version
attributes:
label: Server Version
placeholder: e.g. Paper 1.20.4
validations:
required: true
- type: input
id: ocm-version
attributes:
label: OldCombatMechanics Version
description: "DO NOT write 'latest' - specify the exact version number"
placeholder: e.g. 2.1.1
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other relevant information, screenshots, config snippets, etc.
================================================
FILE: .github/ISSUE_TEMPLATE/3-feature.yaml
================================================
name: Feature Request
description: Suggest an idea for this project
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
## ⚠️ PLEASE READ BEFORE SUBMITTING ⚠️
1. Search for existing enhancement requests first
2. Have a question? Please use the Question template instead
3. This plugin is about combat mechanics changes from 1.9+ - not general Minecraft features
4. Incomplete requests WILL be closed - this is a volunteer project
Remember that this plugin is about changes in combat mechanics following 1.8, anything else is out of scope.
- type: checkboxes
id: confirmation
attributes:
label: Pre-submission checklist
options:
- label: I have searched for existing enhancement requests
required: true
- label: This relates to combat mechanics changes from 1.9+
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: Describe the problem you are facing, as a consequence of a lack of features rather than a bug
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: The solution you think best for the problem
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternative Solutions
description: Describe alternative solutions you have considered
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence of Mechanic Change
description: If your enhancement is about a combat feature that changed between 1.8 and now, provide evidence (wiki links, videos, etc.)
placeholder: Links to Minecraft wiki, videos demonstrating the mechanic, etc.
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context or screenshots that could be relevant
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/release-please-config.json
================================================
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-component-in-tag": false,
"bump-minor-pre-major": true,
"bootstrap-sha": "56dd66a4bf88f252bb063b91835d89a1a1a2e442",
"packages": {
".": {
"package-name": "OldCombatMechanics",
"release-type": "simple",
"extra-files": [
{
"type": "generic",
"path": "build.gradle.kts"
}
],
"changelog-sections": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "perf", "section": "Performance"}
]
}
}
}
================================================
FILE: .github/release-please-manifest.json
================================================
{".":"2.4.0"}
================================================
FILE: .github/workflows/build-upload-release.yml
================================================
name: Build and Release
on:
release:
types: [published]
jobs:
build:
# This permission is still required by the new action
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: wrapper
# Build Step - No changes needed here
- name: Run Gradle Build
run: |
if [ "${{ github.event_name }}" == "release" ]; then
VERSION=${{ github.event.release.tag_name }}
VERSION=${VERSION#v} # Strip 'v' if present
./gradlew clean build -Pversion=$VERSION
else
./gradlew clean build
fi
- name: Upload Artifact to GitHub Release
if: ${{ github.event_name == 'release' }}
uses: softprops/action-gh-release@v2
with:
# The 'files' input takes a path to the asset(s) you want to upload.
# Keep a stable filename for external download links.
files: ./build/libs/OldCombatMechanics.jar
- name: Read game versions from gradle.properties
run: |
RAW=$(grep ^gameVersions gradle.properties | cut -d'=' -f2-)
# Convert "1.21, 1.20.6" -> "1:1.21,1:1.20.6" to select Bukkit-compatible typeId 1 entries via the minecraft endpoint
GAME_VERSIONS=$(echo "$RAW" | tr -d ' ' | awk -F',' '{
out="";
for (i=1; i<=NF; i++) {
v=$i;
if (v!= "") {
if (out!= "") out=out ",";
out=out "1:" v;
}
}
print out;
}')
echo "GAME_VERSIONS=$GAME_VERSIONS" >> "$GITHUB_ENV"
- name: Upload to CurseForge (Bukkit)
if: ${{ github.event_name == 'release' }}
uses: itsmeow/curseforge-upload@v3
with:
token: ${{ secrets.DBO_UPLOAD_API_TOKEN }}
project_id: '98233'
game_endpoint: 'minecraft'
file_path: './build/libs/OldCombatMechanics.jar'
changelog: ${{ github.event.release.body }}
changelog_type: 'markdown'
release_type: 'release'
display_name: 'OldCombatMechanics ${{ github.event.release.tag_name }}'
game_versions: ${{ env.GAME_VERSIONS }}
- name: Publish to Hangar
env:
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
HANGAR_CHANGELOG: ${{ github.event.release.body }}
run: ./gradlew build publishPluginPublicationToHangar --stacktrace
================================================
FILE: .github/workflows/dev-builds.yml
================================================
name: Dev builds
on:
push:
branches:
- master
pull_request:
branches-ignore:
- 'ingametesting'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: wrapper
- name: Determine if this is a release version
run: |
IS_RELEASE=$(./gradlew -q printIsRelease)
echo "Is release? $IS_RELEASE"
echo "IS_RELEASE=$IS_RELEASE" >> $GITHUB_ENV
- name: Run Gradle Build
run: |
./gradlew clean build
- name: Archive jar file
uses: actions/upload-artifact@v4
with:
name: OldCombatMechanics
path: build/libs/OldCombatMechanics.jar
- name: Publish Snapshot to Hangar
if: ${{ github.event_name == 'push' && github.event.pull_request == null && env.IS_RELEASE != 'true' }}
env:
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
run: |
./gradlew publishPluginPublicationToHangar
================================================
FILE: .github/workflows/release-please.yml
================================================
name: Release Please
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: release-please
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/release-please-config.json
manifest-file: .github/release-please-manifest.json
================================================
FILE: .github/workflows/wrap-issue-form-codeblocks.yml
================================================
name: Wrap issue form code blocks
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
wrap:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
script: |
// Avoid loops: when this workflow updates the issue, it triggers "edited" again.
if (context.actor === "github-actions[bot]") return;
const issue = context.payload.issue;
if (!issue || !issue.body) return;
// On edits, bail unless the body actually changed
if (context.payload.action === "edited") {
const changed = context.payload.changes && context.payload.changes.body;
if (!changed) return;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function wrapFirstCodeBlockAfterHeading(body, headingText, summaryText) {
const re = new RegExp(
`(^###\\s+${escapeRegExp(headingText)}\\s*\\n+)(\`\`\`[\\s\\S]*?\\n\`\`\`)(?=\\n|$)`,
"m"
);
return body.replace(re, (_m, heading, codeblock) => {
return (
`${heading}` +
`\n` +
`${summaryText} \n\n` +
`${codeblock}\n\n` +
` `
);
});
}
let body = issue.body;
body = wrapFirstCodeBlockAfterHeading(body, "Server Log File", "Server log");
body = wrapFirstCodeBlockAfterHeading(body, "OldCombatMechanics config.yml", "config.yml");
if (body === issue.body) return;
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body
});
================================================
FILE: .gitignore
================================================
# ===
# == IDE settings files
# ===
# IntelliJ
/*.eml
/*.iml
/.idea
# Eclipse
/.project
/.classpath
/.settings
# ===
# == Compilation output / working files
# ===
out
builds
target
META-INF
/build/
/gradle/
/.gradle/
.gradle-user/
/.gradle-cache/
/.gradle-local/
kls-classpath
kls_database.db
/run/
/bin/
.opencode/
================================================
FILE: AGENTS.md
================================================
# AGENTS.md
**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.
This file captures repo-specific context discovered while working on this branch.
## Repo overview
- Project: OldCombatMechanics (Bukkit/Paper plugin)
- Branch context: working from `kotlin-tests` branch
- Build tool: Gradle (wrapper currently at 9.2.1)
- JDKs used locally: 8, 11, 17, 25
## Integration test harness (Kotlin)
- Integration tests live in `src/integrationTest/kotlin` and are packaged into `OldCombatMechanics--tests.jar`.
- Entrypoint plugin class: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.
- Tests run inside a real Paper server started by the Gradle `run-paper` plugin.
- RunServer output is redirected to `build/integration-test-logs/.log`; `checkTestResults` prints only a compact summary/failures to the console.
- `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.
- 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.
- PacketEvents is shaded into the plugin; integration tests do not inject external packet libraries.
- `relocateIntegrationTestClasses` (ShadowJar) relocates PacketEvents references in test classes only.
- `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.
- `PacketCancellationIntegrationTest` now uses a cancellable `CompletableFuture.await()` so `withTimeout` actually aborts when no packet arrives.
- `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.
- `PacketCancellationIntegrationTest` sets the Bukkit player on the synthetic PacketEvents event so module listeners can match `PacketSendEvent#getPlayer`.
- Matrix task: `integrationTest` depends on `integrationTestMatrix` which runs per-version tasks like:
- `integrationTest1_19_2`, `integrationTest1_21_11`, `integrationTest1_12`, `integrationTest1_9`
- Test result handoff:
- `OCMTestMain` writes `plugins/OldCombatMechanicsTest/test-results.txt` containing `PASS` or `FAIL`.
- `KotestRunner` also writes `plugins/OldCombatMechanicsTest/test-failures.txt` (small failure summary).
- Gradle `checkTestResults` fails build if file missing, or content is not `PASS`.
## Version matrix + Java selection
- Config is in `build.gradle.kts`:
- `integrationTestVersions` from property or defaults.
- `requiredJavaVersion` selects Java based on version.
- Pre-1.13 versions use `integrationTestJavaVersionLegacyPre13` (default 8).
- Modern versions use Java 25 if `>=1.20.5` else Java 17.
- Legacy vanilla jar cache:
- `downloadVanilla` task downloads Mojang server jars for <=1.12 and writes `run//cache/mojang_.jar`.
## Kotlin test runner split (Java 11+ vs Java 8)
- Kotest 6 (Java 11+) is used for 1.19.2 and 1.21.11.
- Kotest runner class: `kernitus.plugin.OldCombatMechanics.KotestRunner`
- Project config: `KotestProjectConfig`
- Java 8 uses a separate runner:
- `kernitus.plugin.OldCombatMechanics.LegacyTestRunner`
- Currently a **smoke test** only (verifies plugin enabled + `WeaponDamages` loaded).
- This is a placeholder and **not a full integration test**.
## Fake player implementation notes
- Primary fake player implementation is `src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt`.
- It relies on `xyz.jpenilla:reflection-remapper` to map modern NMS names.
- On legacy servers (1.12), reflection remapper mappings are unavailable; code falls back to `ReflectionRemapper.noop()`.
- As-is, `FakePlayer` uses modern NMS class names (`net.minecraft.server.MinecraftServer`, etc.).
- This fails on 1.12 which uses versioned NMS (`net.minecraft.server.v1_12_R1.*`).
- 1.12 requires a dedicated fake player path or version-aware class mapping.
## Java 8 compatibility work
- Java 8 compatibility backports already done in main code (records/pattern matching removed).
- Java 8-incompatible APIs replaced:
- `Stream.toList()` -> `collect(Collectors.toList())`
- `Set.of`/`List.of` -> `Collections.unmodifiableSet(new HashSet<>(Arrays.asList(...)))` etc.
- Added missing import in `PotionTypeCompat` for `PotionData`.
- Build config sets `options.release.set(8)` for Java, Kotlin `jvmTarget = 1.8`.
## Dependency / build updates
- Kotlin: 2.3.0
- Kotest: 6.0.7
- run-paper plugin: 3.0.2
- Hangar publish plugin: 0.1.4
- Other deps updated (bstats, netty, BSON, XSeries, authlib, reflection-remapper, adventure).
- PacketEvents is now used for packet interception (shaded and relocated in the main jar).
- PacketEvents dependency moved to `2.11.2-SNAPSHOT` (CodeMC snapshots) for 1.21.11 support.
- JSR-305 added for `javax.annotation.Nullable` (compileOnly).
## Current failing area
- Paper 1.12 integration tests **do not run real fake player tests yet**.
- Desired fix: add proper 1.12 fake player implementation (e.g., version-specific NMS path like `v1_12_R1`).
- Example 1.12 fake player implementation provided by user (NMS, PlayerList manipulation, packet send, etc.).
- We should integrate a conditional path in `FakePlayer` for 1.12 using versioned NMS classes or an alternate helper.
## Local test commands
- Run full matrix:
- `./gradlew integrationTest`
- Change matrix:
- `./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11,1.12`
- Set Java toolchain paths (example):
- `ORG_GRADLE_JAVA_INSTALLATIONS_PATHS=/path/to/jdk8:/path/to/jdk17:/path/to/jdk25 ./gradlew integrationTest`
## Notes
- Removed the dead reflection utility `ClassType` and the unused `Reflector#getClass(ClassType, String)` overload; `Reflector#getClass(String)` remains the supported class-resolution helper.
- `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).
- `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.
- `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.
- 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.
- `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.
- 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.
- 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.
- 1.21.11 servers log hostname warnings and Unsafe warnings; tests still pass.
- 1.9 integration tests are currently on hold per user request.
- Kotest filters (`kotest.filter.specs`, `kotest.filter.tests`) are now passed through Gradle into the run-paper JVM args for integration tests.
- Reflection should be used only as a fallback (performance cost); prefer direct API/code paths when available.
- The Hangar publish workflow exports `HANGAR_API_TOKEN` to match the Gradle Hangar publish configuration.
- `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.
- `AttackCooldownTracker#getLastCooldown` is safe to call when the tracker is not registered (returns null) and uses a `HashMap` rather than a `WeakHashMap`.
- `AttackCooldownTracker` defensively feature-detects `HumanEntity#getAttackCooldown` and avoids scheduling its per-tick sampler when the API exists (modern/backported servers).
- `ModulePlayerKnockback` uses a `HashMap` + a single shared 1-tick expiry cleaner for pending velocity overrides, instead of `WeakHashMap` + one scheduled task per hit.
- `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.
- `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.
- `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.
- `ModuleDisableEnderpearlCooldown` uses a `HashMap` and lazily drops expired cooldown entries during checks (wall-clock cooldown; no recurring task).
- `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.
- Do not gate behaviour on hard-coded Minecraft version numbers; use feature detection (class/method presence) because some servers backport APIs.
- 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.
- 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.
- Added integration tests in `OldPotionEffectsIntegrationTest` for strength addend scaling (Strength II and III), a distinct modifier value check, and strength multiplier scaling.
- Added integration test ensuring vanilla strength addend applies when `old-potion-effects` is disabled.
- Strength modifier in `OCMEntityDamageByEntityEvent` now stores per-level value (3) and applies level when reconstructing base damage.
- Added `OldToolDamageMobIntegrationTest` to assert old-tool-damage config affects vindicator iron-axe hits.
- `KotestRunner` class list updated to include `OldToolDamageMobIntegrationTest`.
- `ModuleOldToolDamage` now adjusts mob weapon damage by shifting base damage with the configured-vs-vanilla delta for non-player damagers.
- `ModuleOldToolDamage` documents that mob custom weapons are not detected; the delta is always applied for mobs and may conflict with other plugins.
- Added `WeaponDurabilityIntegrationTest` covering tool durability vs hit counts during invulnerability and after it expires (FakePlayer attacker vs FakePlayer victim); registered in `KotestRunner`.
- `WeaponDurabilityIntegrationTest` writes debug summaries to `build/weapon-durability-debug-.txt`.
- `WeaponDurabilityIntegrationTest` and `OldToolDamageMobIntegrationTest` now resolve debug output paths relative to the repo root (based on the server run directory), avoiding hard-coded home paths.
- `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.
- 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.
- Use British English spelling and phraseology at all times.
- DO NOT use American English spelling or phraseology under any circumstances.
- Never hard-code absolute filesystem paths in tests or production code; resolve locations relative to the repo root or server run directory.
- Added `DisableOffhandIntegrationTest` to assert the disable-offhand modeset-change handler does not clear the offhand when the module is not enabled for the player.
- `KotestRunner` now includes `DisableOffhandIntegrationTest` in its explicit class list.
- When adding new integration test specs, add them to the explicit `.withClasses(...)` list in `KotestRunner` because autoscan is disabled.
- 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`).
- `old-tool-damage.tooltip.enabled` is now enabled by default in the bundled config so players can see the configured damage in-game.
- Modules are enabled/disabled solely via `always_enabled_modules`, `disabled_modules`, and `modesets` (no per-module `enabled:` toggle).
- `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.
- Added `PacketCancellationIntegrationTest` to cover PacketEvents sweep-particle and attack-sound cancellation using PacketEvents wrappers/listeners (registered in `KotestRunner`).
- Added `ModesetRulesIntegrationTest` to cover always-enabled, disabled, and modeset-scoped module rules plus reload failures for invalid assignments.
- Added `ConfigMigrationIntegrationTest` to cover config upgrade migration into always/disabled module lists and preservation of custom modesets.
- 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).
- `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.
- 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.
- `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.
- `FireAspectOverdamageIntegrationTest` includes afterburn-vs-environmental fire-tick checks for both player and zombie victims, with and without Protection IV armour (mirrors issue 707 MRE).
- 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:` so CurseForge selects the Bukkit-compatible type-1 version entries.
## Test harness shortcuts (known non-realistic paths)
- Several integration tests manually construct and fire Bukkit events rather than triggering real in-world actions:
- `GoldenAppleIntegrationTest` (manual `PlayerItemConsumeEvent` and `PrepareItemCraftEvent`)
- `OldPotionEffectsIntegrationTest` (manual `PlayerItemConsumeEvent`, `PlayerInteractEvent`, `BlockDispenseEvent`)
- `OldArmourDurabilityIntegrationTest` (manual `PlayerItemDamageEvent`, `EntityDamageEvent`)
- `PlayerKnockbackIntegrationTest` (manual `EntityDamageByEntityEvent`, `PlayerVelocityEvent`)
- `SwordBlockingIntegrationTest` (manual `PlayerInteractEvent`)
- `SwordSweepIntegrationTest` (manual `EntityDamageByEntityEvent`)
- Some tests directly invoke module handlers instead of going through the event bus:
- `PlayerKnockbackIntegrationTest` (direct `module.onEntityDamageEntity`)
- `SwordSweepIntegrationTest` (direct `module.onEntityDamaged`)
- `AttributeModifierCompat` synthesises a fallback attack-damage modifier from `NewWeaponDamage` when API attributes are missing.
- Fake player implementations use simulated login/network plumbing (EmbeddedChannel + manual login/join/quit events), not a real networked client.
- 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.
- 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.
- 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.
- 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).
- FakePlayer does not emulate fire-tick damage; fire ticks should be driven by the NMS tick path.
- FakePlayer now forces a world add when the Bukkit world does not report the fake player entity after `placeNewPlayer` to keep PvP interactions reliable.
- FakePlayer now clears invulnerability/instabuild abilities after spawn (plus a legacy fallback) to improve PvP interactions between fake players.
- EntityDamageByEntityListener now logs extra debug about lastDamage restoration for non-entity damage, and documents the vanilla 1.12 damage flow in checkOverdamage.
- 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.
- 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`.
- `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.
- `ModuleSwordSweepParticles` and `ModuleAttackSounds` now use PacketEvents listeners/wrappers instead of ProtocolLib.
- `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.
- 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.
- 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.
- `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.
- Added `ChorusFruitIntegrationTest` (in KotestRunner list) to assert custom chorus teleport distance lands on a safe block within the configured radius.
- 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.
- `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`.
- 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.
- `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+.
- Added `DisableOffhandReflectionIntegrationTest` (in `KotestRunner` list) to ensure reflective access to `InventoryView#getBottomInventory`/`getTopInventory` works on non-public CraftBukkit view implementations.
- `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).
- 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`.
- 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.
- InvulnerabilityDamageIntegrationTest adds a case asserting environmental damage above the baseline applies during invulnerability (manual EntityDamageEvent).
- `gradle.properties` gameVersions list now includes 1.21.11 down to 1.21.1 (plus 1.21) ahead of existing entries.
- GitHub release asset now keeps a stable filename `OldCombatMechanics.jar` (no version suffix); the CurseForge upload uses the same path.
- 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.
- 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.
- 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.
- 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.
- `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.
- `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.
- `ModuleLoader` now clears the static module list on initialise to prevent duplicate registrations after hot reloads.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `config.yml` now notes that ViaVersion clients older than 1.20.5 also fall back to the shield behaviour.
- Added `ConsumableComponentIntegrationTest` coverage for disabling sword-blocking via `disabled_modules` and asserting the consumable component is cleared after config reload.
- Extended `ConsumableComponentIntegrationTest` to cover disabled-module right-click suppression, reload toggling, stored-inventory cleanup, offhand stability, and modeset-change behaviour after a disabled reload.
- Added `ConsumableComponentIntegrationTest` coverage for forcing an older client version and asserting sword-blocking falls back to an offhand shield without applying consumable components.
- `ConsumableComponentIntegrationTest` now uses PacketEvents reflection to seed a User/client version for fake players when PacketEvents has not registered one yet.
- `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.
- Added `ConsumableComponentIntegrationTest` coverage asserting the older-client shield fallback restores the offhand item on hotbar change.
- 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.
- Those inventory-glitch regressions now pass on 1.21.11 after the unknown-client fallback + click/drag scope fixes.
- `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.
- `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.
- `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.
- 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.
- `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.
- `ModuleSwordBlocking#onInventoryClick` also blocks `ClickType.SWAP_OFFHAND` while temporary legacy shield state is active, preventing offhand shield extraction via inventory swap-clicks.
- `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.
- `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.
- `ModuleSwordBlocking#supportsPaperAnimation` now falls back to `User#getClientVersion` when `PlayerManager#getClientVersion` is null, improving old-client fallback stability in synthetic/integration scenarios.
- `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.
- `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.
- `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.
- `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.
- The new legacy-scope regressions now pass on 1.19.2 and 1.21.11.
- `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.
- 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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `ModuleSwordBlocking` inner listener `ConsumableCleaner` was renamed to `ConsumableLifecycleHandler` (registration updated; behaviour unchanged).
- 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.
- Legacy sword-blocking now marks injected temporary offhand shields when marker APIs are available so death handling can identify the temporary drop path reliably.
- `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.
- 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).
- 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.
- `ModuleAttackCooldown` now supports `disable-attack-cooldown.held-item-attack-speeds.` 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.
- `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.
- `.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.
## Fire aspect / fire tick test notes
- `FireAspectOverdamageIntegrationTest` now uses a Zombie victim for real fire tick sampling, with max health boosted (via MAX_HEALTH attribute) to survive rapid clicking.
- The first two tests fire a synthetic `EntityDamageEvent` with `FIRE_TICK` to control timing and make the baseline check deterministic.
- 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.
- 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.
- 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.
## TDAID reminders (this repo)
- Plan → Red → Green → Refactor → Validate.
- Red phase: only touch tests. Do not modify production code.
- Green phase: only touch production code. Do not modify tests.
- Refactor phase: cleanups only; keep behavior unchanged and tests green.
- Validate phase: rerun tests and do a human sanity check before declaring done.
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## [2.4.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.3.0...v2.4.0) (2026-03-08)
### Features
* togglable paper sword blocking ([5d3887d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5d3887d5b18849ec150cabbd459e66104708dda3))
### Bug Fixes
* don't overwrite swords on every click [#843](https://github.com/kernitus/BukkitOldCombatMechanics/issues/843) ([4323853](https://github.com/kernitus/BukkitOldCombatMechanics/commit/432385320c418c0e46df6869e56af051629bfdab))
* **inventory:** harden stale deferred item mutation paths ([a92664d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a92664d293af0dd5fd445ca7db47e078386affd7))
* **reflection:** tighten chooser compatibility fallback ([f383340](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f3833406e4b471a3d89f5e1c5ed8ada9d1d3e1ae))
* strip sword consumable component ([4624ac0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4624ac0c422d7f05835cd511b701026769917292))
* **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)
* **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)
* **sword-blocking:** harden inventory fail-safes to prevent ghosting ([8889bfe](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8889bfeb467c9f64f4d35a2d35e8456f179c35d1))
* **sword-blocking:** harden legacy death shield drop reconciliation ([2bb730e](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2bb730e5c223da5c1cd73c91a093c86d3dedefbb))
* **sword-blocking:** prevent GUI click/drag item rewrites & fallback unknown clients to shield ([cfc596c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfc596c3d674fd99a9c07b4c7c07c354d58fc755))
* **sword-blocking:** prevent legacy fallback from cancelling unrelated shield interactions ([727fa97](https://github.com/kernitus/BukkitOldCombatMechanics/commit/727fa97b4e904ac06514170aae27ba1bc91dbb8b))
* **sword-blocking:** restore offhand item for pre-1.20.5 fallback clients ([68073e8](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68073e876dffa853329c414d156086fe1c76b3fd))
* **sword-blocking:** sweep stale consumable state on join/quit/world ([96d6981](https://github.com/kernitus/BukkitOldCombatMechanics/commit/96d698145e9d8f1543b272adf87d5026076de927))
## [2.3.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.2.0...v2.3.0) (2026-01-24)
### Features
* 1.8 hitbox ([5cbd93d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5cbd93d89dde101dd7e750d4ee13e733b6dfd4a2)), closes [#69](https://github.com/kernitus/BukkitOldCombatMechanics/issues/69)
* always & disabled modules lists ([5f7bf12](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5f7bf127412e2675294d27703ac6562a4c8e0c9d))
* always & disabled modules lists ([18dd6e1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/18dd6e17aeb5e391fbb5704c0726d05e0b676971))
* 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))
* custom trident & mace damage ([be70c5b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/be70c5bb7756699fe4e3cb5bced450df3308f195)), closes [#757](https://github.com/kernitus/BukkitOldCombatMechanics/issues/757)
* item damage lore ([0ca855a](https://github.com/kernitus/BukkitOldCombatMechanics/commit/0ca855a21634c933ab0626770a74f756e57849fe)), closes [#775](https://github.com/kernitus/BukkitOldCombatMechanics/issues/775)
* kotlin integration tests ([c63c940](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c63c940fcc292d4db3a27185a613a01e7c5c04f0))
* switch from protocollib to packetevents ([44afce1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/44afce1a0f01a0ad54b39662e3597a8e371c5454)), closes [#790](https://github.com/kernitus/BukkitOldCombatMechanics/issues/790)
* sword blocking animation [#769](https://github.com/kernitus/BukkitOldCombatMechanics/issues/769) ([8596c9d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8596c9da58dc2167d05ac878d4a7809014ff02d8))
* warn on unknown effects, enchants, etc ([fa828d3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fa828d3469ca18b51ffd0871bd8e510f0c831d1c))
### Bug Fixes
* 'disable-offhand' module working even if disabled ([ecca0b5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ecca0b56d6f8c1d9b2e96d190ae2098dfeb10fc4))
* `disable-offhand` handling on modeset change ([54aaf0c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/54aaf0c1a0a812c89c39ae0c49fe6300760a8ecc))
* apply old tool damage to all mobs ([91b121f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/91b121ff009971586c877778e6a75309088ba667)), closes [#735](https://github.com/kernitus/BukkitOldCombatMechanics/issues/735)
* chorus fruit tp into blocks ([bba1ecb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/bba1ecb62ec9faf1526189f308798d3b25f43cf9)), closes [#748](https://github.com/kernitus/BukkitOldCombatMechanics/issues/748)
* clear modules list on reload ([ebdfd10](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ebdfd101ae1393dbf97c431726b09ea797cc267d))
* 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)
* fire damage overwriting lastDamage ([9af8a0f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9af8a0fa796513ab88588612f5551c5bf582db32)), closes [#707](https://github.com/kernitus/BukkitOldCombatMechanics/issues/707)
* legacy (pre-1.11) sweep detection ([2825b35](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2825b35b4c6b0b879389da170e6d85b9441d9799))
* negative last damage ([1321717](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1321717288ec9624065d86b00aa441f9a1404b52)), closes [#765](https://github.com/kernitus/BukkitOldCombatMechanics/issues/765)
* only strip consumable on swords ([e01faea](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e01faea183d8387371dbd62e89fb847deb2fc38e)), closes [#841](https://github.com/kernitus/BukkitOldCombatMechanics/issues/841)
* reflection error on weapon enchant ([10ff4ce](https://github.com/kernitus/BukkitOldCombatMechanics/commit/10ff4ceead5147b87c13ffea1401ef716596b80c)), closes [#840](https://github.com/kernitus/BukkitOldCombatMechanics/issues/840)
* skip unknown sound packets ([cfa1e58](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfa1e58b5b5e481e1427458d22a98e487b32a621))
* unknown particles. ([1408720](https://github.com/kernitus/BukkitOldCombatMechanics/commit/140872008929ecc49918bdf0cc8e40d226957054)), closes [#825](https://github.com/kernitus/BukkitOldCombatMechanics/issues/825)
* weakness calc on >=1.20 ([f502adb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f502adbbece03875e491bd1f39e4a45d1f498802))
* weakness calculations for amplifier > 1 ([d866015](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d86601504eb6d16481e18946cf07e7432039cd92))
### Performance
* **attack-cooldown-tracker:** gate sampler by API presence and use HashMap for stable caching ([b605501](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b605501b40d350457d5227ce24c9c9a9522ed1f9))
* **disable-enderpearl-cooldown:** use HashMap and lazily drop expired cooldown entries ([83ff726](https://github.com/kernitus/BukkitOldCombatMechanics/commit/83ff726c5a9a2c2e6cc16571ce6e2d37123341ab))
* **fishing-rod-velocity:** replace per-hook gravity tasks with single shared tick runner ([5856bed](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5856bed51830c51b0b6c205cb010fcb8dd9be0ac))
* **old-armour-durability:** replace per-explosion suppression task with shared 1-tick expiry cleaner ([c7932d6](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c7932d6f0b32966ad38cdb81f8c83b14ca438478))
* **old-player-regen:** switch to tick-based interval tracking with shared counter task ([4f8df3c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4f8df3c5d0781240a46a4ed7e1ce10043aafb8d1))
* **player-knockback:** replace per-hit cleanup tasks with shared 1-tick expiry cleaner ([9667ef5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9667ef539e033ede2b97fadf309cd805df6900c5))
* **shield-damage-reduction:** replace per-hit fully-blocked cleanup tasks with shared 1-tick expiry cleaner ([a18b1ef](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a18b1efec039c35b12f2c7c53dcc2be875545b29))
* **sword-block:** reduce amount of recurring tasks ([a08b5b4](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a08b5b47bc5c1645db885ffca794e3ac7d9592ba))
* use one task to clear EDBEE map ([11ec51b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/11ec51bd609339b8e41d74ef2d8697c3083a7d5f))
## [2.2.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.1.0...v2.2.0) (2025-10-14)
### Features
* add fallback sound reflection logic ([1592dc2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1592dc249870ad3113b924cdebd41cbc36b68ad5))
### Bug Fixes
* ocm mode permissions ([e2f0369](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e2f0369f294e250e8cfc474bbd1121498ecf09fe)), closes [#818](https://github.com/kernitus/BukkitOldCombatMechanics/issues/818)
* update checker versioning logic ([8d88a49](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8d88a49d0fa1e8c7dfa7c48cf66c8399c766d6e0))
* use reflection for inventory view ([462e536](https://github.com/kernitus/BukkitOldCombatMechanics/commit/462e536628f3df544c9cf0fe42705eff46b7d4f6)), closes [#812](https://github.com/kernitus/BukkitOldCombatMechanics/issues/812)
### Documentation
* update issue templates ([5927729](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5927729ea5fcca44a6218b10f40cec3f8ce3d4a3))
* update issue templates ([09fd3c5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/09fd3c556ad02d25caa875e7dfe5854888a920cf))
* use yaml issue forms ([fc51924](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fc51924504e3718b9829f4a7deca7a8701000f9f))
## [2.1.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.0.4...v2.1.0) (2025-08-21)
### Features
* compat with 1.21.8 enums ([68c51ab](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68c51ab8803da56f477660af247c37e5171bc581))
* make config upgrader remove deprecated keys ([e728374](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e72837462fb8c512c9971c1e7d9376c82f37e741))
* remove deprecated modules ([da3d5f0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/da3d5f0b28990d8f654b8557e68f54f41e8b5a60))
### Bug Fixes
* 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)
* disable attack sounds not working in >1.21 ([b355322](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b355322f9b3f5e1c2b1e889684d6242f08ceee92)), closes [#794](https://github.com/kernitus/BukkitOldCombatMechanics/issues/794)
* ExceptionInInitialiserError in enchantment compat ([b2379cc](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b2379cc17e309b035c2acb396392723f15ed3ee2)), closes [#782](https://github.com/kernitus/BukkitOldCombatMechanics/issues/782)
* improve sound packet compatibility ([4ef28fb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4ef28fb7bd63631fa4b6f0366d23dd1e159aa115)), closes [#780](https://github.com/kernitus/BukkitOldCombatMechanics/issues/780)
* null pointer in potion compat ([b30e27d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b30e27ddb32d210371152feee8d337f27ea8f495)), closes [#791](https://github.com/kernitus/BukkitOldCombatMechanics/issues/791)
* unmentioned worlds modesets not allowed [#792](https://github.com/kernitus/BukkitOldCombatMechanics/issues/792) ([95c9446](https://github.com/kernitus/BukkitOldCombatMechanics/commit/95c9446edbd0fe56bce0864798cf8d1c70865f8b))
### Refactoring
* improve fishing knockback cross-version compat ([d119c9f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d119c9f35e1a89be8fb8d03573041cfc2ae2d418))
## [2.0.4](https://github.com/kernitus/BukkitOldCombatMechanics/compare/2.0.3...v2.0.4) (2024-10-28)
### Bug Fixes
* avoid ghost items after sword block restore ([822fb1f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/822fb1fa147fc49266cb9f0668869959e341982e)), closes [#749](https://github.com/kernitus/BukkitOldCombatMechanics/issues/749)
* don't prevent moving shield to chests in disable offhand module ([b299df2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b299df2d21ace1c7e88b1ee8fafb297e2a9347e8))
* error when right clicking air while holding block in <1.13 ([cbc0c4b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cbc0c4bc8bf0afd56005699ce70f86ec9b637646)), closes [#754](https://github.com/kernitus/BukkitOldCombatMechanics/issues/754)
* listen to dynamically loaded worlds for modesets ([f5b59d7](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f5b59d7537d410fac35fbb4e0181a61a485ae1a5)), closes [#747](https://github.com/kernitus/BukkitOldCombatMechanics/issues/747)
* 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)
* 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)
================================================
FILE: LICENCE
================================================
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
================================================
FILE: README.md
================================================
## by kernitus and Rayzr522
Fine‑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.
**Why servers pick OCM** ✨
- 🧩 **Modular:** enable only what you need: cooldowns, tool damage, knockback, shields, potions, reach, sounds, more.
- 🚀 **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.
- 🗺️ **Modesets:** ship different rules for different worlds or players; perfect for mixed PvP/PvE, minigames, or duels.
- ⏪ **Backwards‑friendly:** runs on Java 8+, supports 1.9 to latest; integrates cleanly with PlaceholderAPI and PacketEvents.
- ✅ **Tested for you:** live integration tests run real Paper servers across multiple versions every build.
- 💸 **Zero cost:** fully open source, optional basic telemetry (bStats only), no paywalls.
**Quick start** ⚡
1. Drop the jar into `plugins/` (Spigot or Paper-derivatives 1.9+).
2. Restart and edit `config.yml` to pick your modules and modesets.
3. Use `/ocm reload` to apply changes instantly.
4. Hand players `/ocm modeset ` to let them choose their ruleset.
## 🧰 Modesets
- Per-player/per-world presets that decide which features are active; each world has an allowed list and a default modeset.
- Let players pick ( `/ocm modeset ` ) to run, for example, 1.8-style PvP in an arena world while keeping vanilla rules in survival.
## ⚙ Configurable Features
Features are grouped in `module`s as listed below, and can be individually configured and disabled. Disabled modules will have no impact on server performance.
#### ⚔ Combat
*Tweak timing, damage, and reach.*
- **Attack cooldown:** adjust or remove 1.9+ cooldown
- **Attack frequency:** set global hit delay
- **Tool damage:** pre-1.9 weapon values
- **Attack range (Paper 1.21.11+):** 1.8-style reach
- **Critical hits:** control crit multiplier
- **Player regen:** tune regen rates
#### 🤺 Armour
*Balance defence and wear.*
- **Armour strength:** scale armour protection
- **Armour durability:** change durability loss
#### 🛡 Swords & Shields
*Control block and sweep behaviour.*
- **Sword blocking:** restore old right-click block; on Paper 1.21.2+ we also add the native sword blocking animation via the consumable component
- **Shield damage reduction:** scale shield protection
- **Sword sweep:** enable or disable sweeps
- **Sword sweep particles:** hide or show sweep visuals
#### 🌬 Knockback
*Shape knockback per source.*
- **Player knockback:** adjust PvP knockback
- **Fishing knockback:** fishing-rod knockback
- **Fishing rod velocity:** pull speed
- **Projectile knockback:** arrows and other projectiles
#### 🧙 Gapples & Potions
*Change consumable power.*
- **Golden apple crafting and effects:** notch and normal
- **Potion effects and duration:** old-style values
- **Chorus fruit:** teleport behaviour and range
#### ❌ New feature disabling
*Toggle later-version mechanics.*
- **Item crafting:** block selected recipes
- **Offhand:** disable offhand use
- **New attack sounds:** mute new swing sounds
- **Enderpearl cooldown:** enable or remove cooldown
- **Brewing stand refuel:** alter fuel use
- **Burn delay:** adjust fire tick delay
## 🔌 Compatibility & Testing
- OCM targets Spigot 1.9+ and runs on Java 8 and up.
- We stick to Spigot/Paper APIs for forward compatibility; NMS/reflection is used only when necessary.
- Integration tests boot real servers on 1.9.4, 1.12, 1.19.2, and 1.21.11 each build to verify behaviour.
- Most plugins work fine with OCM. Explicitly tested integrations include PlaceholderAPI (see [wiki](https://github.com/kernitus/BukkitOldCombatMechanics/wiki/PlaceholderAPI)).
## 🧾 Licence
- Source code in this repository is under the Mozilla Public License 2.0 (MPL‑2.0).
- Pre-built jars bundle PacketEvents (GPLv3). Those binary distributions are provided under GPLv3 terms due to the included dependency.
- If you build a jar without PacketEvents, you may distribute that build under MPL‑2.0, subject to its terms.
## ⚡ Development Builds
Oftentimes a particular bug fix or feature has already been implemented, but a new version of OCM has not been released
yet. You can find the most up-to-date version of the plugin
on [Hangar](https://hangar.papermc.io/kernitus/OldCombatMechanics/versions?channel=Snapshot&platform=PAPER).
## 🤝 Contributions
If you are interested in contributing, please [check this page first](.github/CONTRIBUTING.md).
================================================
FILE: build.gradle.kts
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import groovy.json.JsonSlurper
import io.papermc.hangarpublishplugin.model.Platforms
import org.gradle.api.Action
import org.gradle.api.attributes.java.TargetJvmVersion
import org.gradle.api.file.FileCopyDetails
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import xyz.jpenilla.runpaper.task.RunServer
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.Serializable
import java.net.URI
import java.nio.file.Files
import java.security.MessageDigest
val paperVersion: List =
(property("gameVersions") as String)
.split(",")
.map { it.trim() }
plugins {
`java-library`
kotlin("jvm") version "2.3.0"
id("com.gradleup.shadow") version "9.3.0"
id("xyz.jpenilla.run-paper") version "3.0.2"
idea
id("io.papermc.hangar-publish-plugin") version "0.1.4"
}
// Make sure javadocs are available to IDE
idea {
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
// Spigot API
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
maven("https://oss.sonatype.org/content/repositories/snapshots")
maven("https://oss.sonatype.org/content/repositories/central")
// PacketEvents
maven("https://repo.codemc.io/repository/maven-releases/")
maven("https://repo.codemc.io/repository/maven-snapshots/")
// Placeholder API
maven("https://repo.extendedclip.com/content/repositories/placeholderapi/")
// CodeMC Repo for bStats
maven("https://repo.codemc.org/repository/maven-public/")
// Auth library from Minecraft
maven("https://libraries.minecraft.net/")
}
group = "kernitus.plugin.OldCombatMechanics"
version = "2.5.0-beta" // x-release-please-version
description = "OldCombatMechanics"
java {
toolchain {
// We can build with Java 17 but still support MC >=1.9
// This is because MC >=1.9 server can be run with higher Java versions
languageVersion.set(JavaLanguageVersion.of(17))
}
}
sourceSets {
val integrationTest by creating {
kotlin.setSrcDirs(listOf("src/integrationTest/kotlin"))
resources.setSrcDirs(listOf("src/integrationTest/resources"))
compileClasspath += main.get().output
runtimeClasspath += output + main.get().output
}
}
configurations {
val integrationTestImplementation by getting {
extendsFrom(configurations.implementation.get())
}
create("integrationTestServerPlugins") {
isCanBeConsumed = false
isCanBeResolved = true
}
}
configurations.named("compileClasspath") {
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
}
configurations.named("integrationTestCompileClasspath") {
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
}
dependencies {
implementation("org.bstats:bstats-bukkit:3.1.0")
// Shaded in by Bukkit
compileOnly("io.netty:netty-all:4.1.130.Final")
// Placeholder API
compileOnly("me.clip:placeholderapi:2.11.6")
// For BSON file serialisation
implementation("org.mongodb:bson:5.6.2")
// Spigot
compileOnly("org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
// JSR-305 annotations (javax.annotation.Nullable)
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
// PacketEvents
implementation("com.github.retrooper:packetevents-spigot:2.11.2")
// XSeries
implementation("com.github.cryptomorin:XSeries:13.6.0")
// For ingametesting
// Mojang mappings for NMS
/*
compileOnly("com.mojang:authlib:6.0.54")
paperweight.paperDevBundle("1.19.2-R0.1-SNAPSHOT")
// For reflection remapping
implementation("xyz.jpenilla:reflection-remapper:0.1.3")
*/
// Integration test dependencies
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.0")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.0")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-test:2.3.0")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-reflect:2.3.0")
add("integrationTestImplementation", "io.kotest:kotest-runner-junit5-jvm:5.9.1")
add("integrationTestImplementation", "io.kotest:kotest-assertions-core-jvm:5.9.1")
add("integrationTestImplementation", "net.kyori:adventure-api:4.26.1")
add("integrationTestImplementation", "xyz.jpenilla:reflection-remapper:0.1.3")
add("integrationTestCompileOnly", "org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
add("integrationTestCompileOnly", "com.mojang:authlib:6.0.54")
add("integrationTestCompileOnly", "io.netty:netty-all:4.1.130.Final")
}
// Substitute ${pluginVersion} in plugin.yml with version defined above
class ExpandPluginVersionAction(
private val version: String,
) : Action,
Serializable {
override fun execute(details: FileCopyDetails) {
details.expand(mapOf("pluginVersion" to version))
}
}
val pluginVersion = project.version.toString()
val expandPluginVersionAction = ExpandPluginVersionAction(pluginVersion)
tasks.named("processResources") {
inputs.property("pluginVersion", pluginVersion)
filesMatching("plugin.yml", expandPluginVersionAction)
}
tasks.withType {
options.encoding = "UTF-8"
options.release.set(8)
}
val shadowJarTask =
tasks.named("shadowJar") {
dependsOn("jar")
archiveFileName.set("${project.name}.jar")
dependencies {
exclude(dependency("org.jetbrains.kotlin:.*"))
relocate("org.bstats", "kernitus.plugin.OldCombatMechanics.lib.bstats")
relocate("com.cryptomorin.xseries", "kernitus.plugin.OldCombatMechanics.lib.xseries")
relocate("com.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.api")
relocate("io.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.impl")
}
}
// For ingametesting
/*
tasks.reobfJar {
outputJar.set(File(buildDir, "libs/${project.name}.jar"))
}
*/
tasks.assemble {
// For ingametesting
// dependsOn("reobfJar")
dependsOn("shadowJar")
}
kotlin {
jvmToolchain(17)
}
tasks.withType().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)
}
val relocateIntegrationTestClasses =
tasks.register("relocateIntegrationTestClasses") {
archiveClassifier.set("tests-relocated")
dependsOn("compileIntegrationTestKotlin")
configurations = emptyList()
from(sourceSets["integrationTest"].output)
relocate("com.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.api")
relocate("io.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.impl")
}
val integrationTestJarTask =
tasks.register("integrationTestJar") {
archiveClassifier.set("tests")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn(relocateIntegrationTestClasses)
from(relocateIntegrationTestClasses.flatMap { it.archiveFile }.map { zipTree(it.asFile) })
project.configurations["integrationTestRuntimeClasspath"].forEach { file: File ->
if (file.name.contains("packetevents", ignoreCase = true)) {
return@forEach
}
from(if (file.isDirectory) file else zipTree(file))
}
exclude("META-INF/*.SF")
exclude("META-INF/*.DSA")
exclude("META-INF/*.RSA")
exclude("META-INF/*.EC")
exclude("META-INF/*.MF")
exclude("module-info.class")
exclude("META-INF/versions/**/module-info.class")
}
val integrationTestMinecraftVersion =
(findProperty("integrationTestMinecraftVersion") as String?) ?: "1.19.2"
val defaultIntegrationTestVersions =
listOf(integrationTestMinecraftVersion, "1.21.11", "1.12", "1.9.4")
.distinct()
val integrationTestVersions: List =
(findProperty("integrationTestVersions") as String?)
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { defaultIntegrationTestVersions }
?: defaultIntegrationTestVersions
val integrationTestJavaVersionLegacy =
(findProperty("integrationTestJavaVersionLegacy") as String?)?.toInt() ?: 17
val integrationTestJavaVersionLegacyPre13 =
(findProperty("integrationTestJavaVersionLegacyPre13") as String?)?.toInt() ?: 8
val integrationTestJavaVersionLegacy16 =
(findProperty("integrationTestJavaVersionLegacy16") as String?)?.toInt() ?: 11
val integrationTestJavaVersionModern =
(findProperty("integrationTestJavaVersionModern") as String?)?.toInt() ?: 25
fun parseMinecraftVersion(version: String): Triple {
val parts = version.split(".")
val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
return Triple(major, minor, patch)
}
fun needsLegacyVanillaJar(version: String): Boolean {
val (major, minor, _) = parseMinecraftVersion(version)
return major == 1 && minor <= 12
}
fun requiresModernJava(version: String): Boolean {
val (major, minor, patch) = parseMinecraftVersion(version)
if (major > 1) return true
if (minor > 20) return true
return minor == 20 && patch >= 5
}
fun requiredJavaVersion(version: String): Int {
if (needsLegacyVanillaJar(version)) return integrationTestJavaVersionLegacyPre13
val (_, minor, _) = parseMinecraftVersion(version)
if (minor <= 16) return integrationTestJavaVersionLegacy16
return if (requiresModernJava(version)) integrationTestJavaVersionModern else integrationTestJavaVersionLegacy
}
data class KotestSummary(
val specsPassed: Int?,
val specsFailed: Int?,
val specsTotal: Int?,
val testsPassed: Int?,
val testsFailed: Int?,
val testsIgnored: Int?,
val testsTotal: Int?,
val failures: List,
val failureDetails: List,
)
fun parseKotestSummary(logFile: File): KotestSummary? {
if (!logFile.exists()) return null
val lines = logFile.readLines()
var inFailures = false
var blockContext: String? = null // "specs" or "tests"
var specsPassed: Int? = null
var specsFailed: Int? = null
var specsTotal: Int? = null
var testsPassed: Int? = null
var testsFailed: Int? = null
var testsIgnored: Int? = null
var testsTotal: Int? = null
val failures = mutableListOf()
val failureDetails = mutableListOf()
val numberLine = Regex("^(\\d+) (passed|failed|ignored|total)$")
val inlineSpecsLine = Regex("^Specs:\\s*(\\d+) passed,\\s*(\\d+) failed,\\s*(\\d+) total$")
val inlineTestsLine = Regex("^Tests:\\s*(\\d+) passed,\\s*(\\d+) failed,\\s*(\\d+) ignored,\\s*(\\d+) total$")
val stackTopLine = Regex("^\\s*[^\\s].*\\(([^)]+:\\d+)\\)\\s*$")
var lastTestName: String? = null
var pendingFailureName: String? = null
var pendingFailureMessage: String? = null
for (raw in lines) {
val line = raw.substringAfter("]:", raw).trim()
when {
line.startsWith(">> There were test failures") -> {
inFailures = true
blockContext = null
}
line.startsWith("Specs:") -> {
inFailures = false
val inline = inlineSpecsLine.matchEntire(line)
if (inline != null) {
specsPassed = inline.groupValues[1].toInt()
specsFailed = inline.groupValues[2].toInt()
specsTotal = inline.groupValues[3].toInt()
blockContext = null
} else {
blockContext = "specs"
}
}
line.startsWith("Tests:") -> {
inFailures = false
val inline = inlineTestsLine.matchEntire(line)
if (inline != null) {
testsPassed = inline.groupValues[1].toInt()
testsFailed = inline.groupValues[2].toInt()
testsIgnored = inline.groupValues[3].toInt()
testsTotal = inline.groupValues[4].toInt()
blockContext = null
} else {
blockContext = "tests"
}
}
inFailures -> {
val cleaned = line.removePrefix("-").trim()
if (cleaned.isNotBlank()) {
failures.add(cleaned)
}
}
line.startsWith("- ") -> {
lastTestName = line.removePrefix("-").trim()
}
line == "FAILED" -> {
pendingFailureName = lastTestName
pendingFailureMessage = null
}
pendingFailureName != null && pendingFailureMessage == null && line.isNotBlank() -> {
// First line after FAILED is usually the assertion message.
pendingFailureMessage = line
}
pendingFailureName != null && pendingFailureMessage != null -> {
val match = stackTopLine.matchEntire(line)
if (match != null) {
val location = match.groupValues[1]
val name = pendingFailureName!!
val message = pendingFailureMessage!!
failureDetails.add("$name: $message ($location)")
pendingFailureName = null
pendingFailureMessage = null
}
}
blockContext != null -> {
val match = numberLine.matchEntire(line)
if (match != null) {
val value = match.groupValues[1].toInt()
when (blockContext) {
"specs" -> {
when (match.groupValues[2]) {
"passed" -> specsPassed = value
"failed" -> specsFailed = value
"total" -> specsTotal = value
}
}
"tests" -> {
when (match.groupValues[2]) {
"passed" -> testsPassed = value
"failed" -> testsFailed = value
"ignored" -> testsIgnored = value
"total" -> testsTotal = value
}
}
}
}
}
}
}
if (
specsPassed == null && specsFailed == null && specsTotal == null &&
testsPassed == null && testsFailed == null && testsIgnored == null && testsTotal == null &&
failures.isEmpty()
) {
return null
}
return KotestSummary(
specsPassed,
specsFailed,
specsTotal,
testsPassed,
testsFailed,
testsIgnored,
testsTotal,
failures,
failureDetails.distinct(),
)
}
fun sha1(file: File): String {
val digest = MessageDigest.getInstance("SHA-1")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
tasks.register("integrationTest") {
group = "verification"
description = "Runs integration tests against all configured Paper versions."
dependsOn("integrationTestMatrix")
}
tasks.named("runServer") {
enabled = false
description = "Disabled. Use integrationTest/integrationTestMatrix instead."
}
tasks.withType().configureEach {
notCompatibleWithConfigurationCache("run-paper tasks access Project at execution time.")
}
val integrationTestMatrixTasks = mutableListOf>()
var previousMatrixTask: TaskProvider? = null
val kotestSpecFilterProvider = providers.systemProperty("kotest.filter.specs")
val kotestTestFilterProvider = providers.systemProperty("kotest.filter.tests")
fun versionTaskSuffix(version: String): String = version.replace(Regex("[^A-Za-z0-9]"), "_")
for (version in integrationTestVersions) {
val suffix = versionTaskSuffix(version)
val runDir = file("run/$version")
val resultFile = runDir.resolve("plugins/OldCombatMechanicsTest/test-results.txt")
val failuresFile = runDir.resolve("plugins/OldCombatMechanicsTest/test-failures.txt")
val vanillaCacheFile = runDir.resolve("cache/mojang_$version.jar")
val logFile = layout.buildDirectory.file("integration-test-logs/$suffix.log")
val writePropsTask =
tasks.register("writeProperties$suffix") {
encoding = "UTF-8"
property("online-mode", false)
destinationFile.set(runDir.resolve("server.properties"))
}
val downloadVanillaTask =
if (needsLegacyVanillaJar(version)) {
tasks.register("downloadVanilla$suffix") {
outputs.file(vanillaCacheFile)
notCompatibleWithConfigurationCache("Downloads vanilla server jar for legacy Paper versions.")
doLast {
val slurper = JsonSlurper()
val manifestText =
URI("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")
.toURL()
.readText()
val manifest = slurper.parseText(manifestText) as Map<*, *>
val versionsList =
manifest["versions"] as? List>
?: throw GradleException("Invalid Mojang manifest format: missing 'versions' list.")
val versionEntry =
versionsList.firstOrNull { it["id"] == version }
?: throw GradleException("Minecraft version '$version' not found in Mojang manifest.")
val versionUrl = versionEntry["url"] as String
val versionMetaText = URI(versionUrl).toURL().readText()
val versionMeta = slurper.parseText(versionMetaText) as Map<*, *>
val downloads = versionMeta["downloads"] as Map<*, *>
val serverInfo = downloads["server"] as Map<*, *>
val serverUrl = serverInfo["url"] as String
val serverSha1 = serverInfo["sha1"] as String
if (vanillaCacheFile.exists()) {
val existingSha1 = sha1(vanillaCacheFile)
if (existingSha1.equals(serverSha1, ignoreCase = true)) {
return@doLast
}
} else {
vanillaCacheFile.parentFile.mkdirs()
}
val tmpFile = Files.createTempFile("mc-server-$version-", ".jar").toFile()
URI(serverUrl).toURL().openStream().use { input ->
tmpFile.outputStream().use { output -> input.copyTo(output) }
}
val downloadedSha1 = sha1(tmpFile)
if (!downloadedSha1.equals(serverSha1, ignoreCase = true)) {
tmpFile.delete()
throw GradleException(
"Downloaded Minecraft server jar hash mismatch for $version. Expected $serverSha1, got $downloadedSha1.",
)
}
tmpFile.copyTo(vanillaCacheFile, overwrite = true)
tmpFile.delete()
}
}
} else {
null
}
val runServerTask =
tasks.register("runServer$suffix") {
dependsOn(writePropsTask)
downloadVanillaTask?.let { dependsOn(it) }
runDirectory.set(runDir)
minecraftVersion(version)
jvmArgs("-Dcom.mojang.eula.agree=true")
kotestSpecFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {
jvmArgs("-Dkotest.filter.specs=$it")
}
kotestTestFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {
jvmArgs("-Dkotest.filter.tests=$it")
}
if (needsLegacyVanillaJar(version)) {
// Skip the legacy Paper "outdated build" startup sleep.
jvmArgs("-DIReallyKnowWhatIAmDoingISwear=true")
}
javaLauncher.set(
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(requiredJavaVersion(version)))
},
)
pluginJars.from(shadowJarTask.flatMap { it.archiveFile })
pluginJars.from(integrationTestJarTask.flatMap { it.archiveFile })
pluginJars.from(configurations["integrationTestServerPlugins"])
doFirst {
val log = logFile.get().asFile
log.parentFile.mkdirs()
val stream = log.outputStream()
standardOutput = stream
errorOutput = stream
}
doLast {
(standardOutput as? Closeable)?.close()
}
doFirst {
if (resultFile.exists()) {
resultFile.delete()
}
if (failuresFile.exists()) {
failuresFile.delete()
}
val ocmConfigFile = runDir.resolve("plugins/OldCombatMechanics/config.yml")
if (ocmConfigFile.exists()) {
ocmConfigFile.delete()
}
}
}
val checkTask =
tasks.register("checkTestResults$suffix") {
doLast {
if (!resultFile.exists()) {
throw GradleException("Test results file not found for $version. Tests may not have run correctly.")
}
val result = resultFile.readText().trim()
val log = logFile.get().asFile
val summary = parseKotestSummary(log)
summary?.let {
val parts = mutableListOf()
if (it.specsTotal != null) {
parts.add("Specs: ${it.specsPassed ?: "?"} passed, ${it.specsFailed ?: "?"} failed, ${it.specsTotal} total")
}
if (it.testsTotal != null) {
parts.add(
"Tests: ${it.testsPassed ?: "?"} passed, ${it.testsFailed ?: "?"} failed, ${it.testsIgnored ?: 0} ignored, ${it.testsTotal} total",
)
}
if (it.failures.isNotEmpty()) {
parts.add("Failures: ${it.failures.joinToString(", ")}")
}
if (it.failureDetails.isNotEmpty()) {
parts.add("Reasons: ${it.failureDetails.take(2).joinToString("; ")}")
}
if (parts.isNotEmpty()) {
logger.lifecycle("[$version] ${parts.joinToString(" | ")}")
}
} ?: run {
val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath
logger.lifecycle("[$version] No Kotest summary parsed. Full log: $rel")
}
run {
val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath
logger.lifecycle("[$version] Log: $rel")
}
if (failuresFile.exists()) {
val lines = failuresFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
if (lines.isNotEmpty()) {
logger.lifecycle("[$version] Failure details: ${lines.take(5).joinToString(" | ")}")
}
}
if (result == "FAIL") {
throw GradleException("Integration tests failed for $version.")
} else if (result != "PASS") {
throw GradleException("Unknown test result for $version: $result")
}
logger.lifecycle("Integration tests passed for $version.")
}
}
val testTask =
tasks.register("integrationTest$suffix") {
group = "verification"
description = "Runs integration tests with a live Paper server ($version)."
dependsOn(shadowJarTask, integrationTestJarTask, runServerTask)
finalizedBy(checkTask)
}
val priorTask = previousMatrixTask
if (priorTask != null) {
// Chain tasks to enforce order and fail fast.
testTask.configure { dependsOn(priorTask) }
}
previousMatrixTask = testTask
integrationTestMatrixTasks.add(testTask)
}
tasks.register("integrationTestMatrix") {
group = "verification"
description = "Runs integration tests against multiple Paper versions."
dependsOn(integrationTestMatrixTasks)
}
val versionStringProvider = providers.provider { project.version.toString() }
val isReleaseProvider = versionStringProvider.map { !it.contains('-') }
val gitShortHashProvider =
providers
.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText
.map { it.trim() }
val gitChangelogProvider =
providers
.exec {
commandLine("git", "log", "-1", "--pretty=%B")
}.standardOutput.asText
.map { it.trim() }
val suffixedVersionProvider =
providers.provider {
val version = project.version.toString()
if (!version.contains('-')) {
version
} else {
"$version+${gitShortHashProvider.get()}"
}
}
val changelogProvider = providers.environmentVariable("HANGAR_CHANGELOG").orElse(gitChangelogProvider)
tasks.register("printIsRelease") {
doLast {
println(if (!project.version.toString().contains('-')) "true" else "false")
}
}
hangarPublish {
publications.register("plugin") {
version.set(suffixedVersionProvider)
channel.set(isReleaseProvider.map { if (it) "Release" else "Snapshot" })
id.set("OldCombatMechanics")
apiKey.set(System.getenv("HANGAR_API_TOKEN"))
changelog.set(changelogProvider)
platforms {
register(Platforms.PAPER) {
jar.set(tasks.shadowJar.flatMap { it.archiveFile })
platformVersions.set(paperVersion)
}
}
}
}
================================================
FILE: gradle.properties
================================================
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
gameVersions=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
# Disable configuration cache (run-paper tasks are not compatible).
org.gradle.configuration-cache=false
================================================
FILE: gradlew
================================================
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: settings.gradle.kts
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
rootProject.name = "OldCombatMechanics"
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCompat.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.entity.Entity
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import java.lang.reflect.Method
import java.util.concurrent.ConcurrentHashMap
fun attackCompat(attacker: Player, target: Entity) {
val apiAttack = attacker.javaClass.methods.firstOrNull { method ->
method.name == "attack" &&
method.parameterCount == 1 &&
Entity::class.java.isAssignableFrom(method.parameterTypes[0])
}
val useApiAttack = Reflector.versionIsNewerOrEqualTo(1, 12, 0)
if (useApiAttack && apiAttack != null) {
val beforeApiAttack = captureLivingAttackSignal(target)
try {
val apiResult = apiAttack.invoke(attacker, target)
if (apiAttack.returnType == java.lang.Boolean.TYPE && apiResult == false) {
// Explicit attack failure; continue with NMS candidates.
} else if (beforeApiAttack == null) {
return
} else {
val afterApiAttack = captureLivingAttackSignal(target)
if (hasObservableHit(beforeApiAttack, afterApiAttack)) {
return
}
}
} catch (ignored: Exception) {
// Fall through to NMS-based attack.
}
}
// Fall back to NMS attack on legacy servers.
val handleMethod = attacker.javaClass.methods.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
} ?: error("Failed to resolve CraftPlayer#getHandle for ${attacker.javaClass.name}")
val attackerHandle = handleMethod.invoke(attacker)
?: error("CraftPlayer#getHandle returned null for ${attacker.javaClass.name}")
val targetHandle = target.javaClass.methods.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
}?.invoke(target) ?: error("Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}")
val nmsAttackMethods = resolveNmsAttackMethods(attackerHandle.javaClass, targetHandle.javaClass)
var falseResultCount = 0
var exceptionCount = 0
val attemptedMethods = ArrayList(nmsAttackMethods.size)
for (method in nmsAttackMethods) {
attemptedMethods += "${method.declaringClass.simpleName}#${method.name}:${method.returnType.simpleName}"
try {
val result = method.invoke(attackerHandle, targetHandle)
if (method.returnType == java.lang.Boolean.TYPE && result == false) {
falseResultCount++
continue
}
return
} catch (ignored: Exception) {
exceptionCount++
// Try the next candidate.
}
}
// Legacy fallback: try Bukkit API even if we prefer NMS (helps 1.12 fake players)
if (!useApiAttack && apiAttack != null) {
runCatching { apiAttack.invoke(attacker, target); return }
}
error(
"Failed to invoke NMS attack for attacker=${attackerHandle.javaClass.name} " +
"target=${targetHandle.javaClass.name} (candidates=${nmsAttackMethods.size}, " +
"falseResults=$falseResultCount, exceptions=$exceptionCount, attempted=$attemptedMethods)"
)
}
private data class LivingAttackSignal(
val health: Double,
val lastDamage: Double,
val noDamageTicks: Int,
)
private fun captureLivingAttackSignal(entity: Entity): LivingAttackSignal? {
val living = entity as? LivingEntity ?: return null
return LivingAttackSignal(
health = living.health,
lastDamage = living.lastDamage,
noDamageTicks = living.noDamageTicks,
)
}
private fun hasObservableHit(before: LivingAttackSignal, after: LivingAttackSignal?): Boolean {
if (after == null) return false
if (after.health < before.health) return true
if (after.noDamageTicks > before.noDamageTicks) return true
return after.lastDamage > 0.0 && after.lastDamage != before.lastDamage
}
private val attackMethodCache = ConcurrentHashMap, List>()
private fun resolveNmsAttackMethods(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List {
return attackMethodCache.computeIfAbsent(attackerHandleClass) {
buildAttackMethodCandidates(attackerHandleClass, targetHandleClass)
}
}
private fun buildAttackMethodCandidates(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List {
// Prefer explicit names if they exist, then fall back to signature-based heuristics.
val explicit = listOfNotNull(
Reflector.getMethodAssignable(attackerHandleClass, "attack", targetHandleClass),
Reflector.getMethodAssignable(attackerHandleClass, "a", targetHandleClass),
Reflector.getMethodAssignable(attackerHandleClass, "B", targetHandleClass) // legacy 1.12 variants
)
if (explicit.isNotEmpty()) {
explicit.forEach { it.isAccessible = true }
return explicit
}
val candidates = collectAllMethods(attackerHandleClass)
.asSequence()
.filter { it.parameterCount == 1 }
.filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
.map { method -> method to scoreAttackMethod(method) }
.sortedByDescending { it.second }
.map { it.first }
.toList()
candidates.forEach { it.isAccessible = true }
return candidates
}
private fun collectAllMethods(start: Class<*>): List {
val methods = LinkedHashMap()
var current: Class<*>? = start
while (current != null) {
current.declaredMethods.forEach { method ->
methods.putIfAbsent(methodSignature(method), method)
}
current = current.superclass
}
start.methods.forEach { method ->
methods.putIfAbsent(methodSignature(method), method)
}
return methods.values.toList()
}
private fun methodSignature(method: Method): String {
val params = method.parameterTypes.joinToString(",") { it.name }
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
}
private fun scoreAttackMethod(method: Method): Int {
var score = 0
val name = method.name
val param = method.parameterTypes[0]
val declaring = method.declaringClass.simpleName
if (name == "attack") score += 100
if (name == "a") score += 80
if (param.simpleName == "Entity") score += 40
if (param.simpleName.contains("Entity")) score += 10
if (method.returnType == Void.TYPE) score += 10
if (method.returnType == java.lang.Boolean.TYPE) score += 8
if (declaring.contains("EntityHuman")) score += 25
if (declaring.contains("EntityPlayer")) score += 20
return score
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCooldownHeldItemIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackCooldown
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.World
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerSwapHandItemsEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class AttackCooldownHeldItemIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
fun currentAttackSpeed(player: Player): Double {
val attackSpeedAttribute = checkNotNull(XAttribute.ATTACK_SPEED.get()) { "Missing attack speed attribute type" }
val attribute = player.getAttribute(attackSpeedAttribute) ?: error("Missing attack speed attribute")
return attribute.baseValue
}
fun setModeset(
player: Player,
world: World,
modeset: String,
) {
val data = PlayerStorage.getPlayerData(player.uniqueId)
data.setModesetForWorld(world.uid, modeset)
PlayerStorage.setPlayerData(player.uniqueId, data)
}
fun fireJoin(player: Player) {
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, "test"))
}
fun switchHotbar(
player: Player,
from: Int,
to: Int,
) {
player.inventory.heldItemSlot = to
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))
}
data class SpawnedPlayer(
val fake: FakePlayer,
val player: Player,
)
fun spawnFake(world: World): SpawnedPlayer {
lateinit var fake: FakePlayer
lateinit var player: Player
runSync {
fake = FakePlayer(testPlugin)
fake.spawn(Location(world, 0.0, 100.0, 0.0, 0f, 0f))
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.inventory.clear()
player.inventory.heldItemSlot = 0
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
setModeset(player, world, "old")
}
return SpawnedPlayer(fake, player)
}
fun cleanup(spawnedPlayer: SpawnedPlayer) {
runSync { spawnedPlayer.fake.removePlayer() }
}
suspend fun waitForPossibleDeferredWork() {
delay(2 * 50L)
}
suspend fun withAttackCooldownConfig(
genericAttackSpeed: Double,
heldItemAttackSpeeds: Map,
block: suspend () -> Unit,
) {
val disabledModules = ocm.config.getStringList("disabled_modules")
val alwaysEnabledModules = ocm.config.getStringList("always_enabled_modules")
val modesetsSection = ocm.config.getConfigurationSection("modesets") ?: error("Missing 'modesets' section")
val modesetSnapshot =
modesetsSection.getKeys(false).associateWith { key ->
ocm.config.getStringList("modesets.$key")
}
val genericSnapshot = ocm.config.get("disable-attack-cooldown.generic-attack-speed")
val heldItemSnapshot =
ocm.config.getConfigurationSection("disable-attack-cooldown.held-item-attack-speeds")?.getValues(false)
?: emptyMap()
fun reloadAll() {
ocm.saveConfig()
Config.reload()
ModuleLoader.toggleModules()
module?.reload()
}
try {
ocm.config.set("disable-attack-cooldown.generic-attack-speed", genericAttackSpeed)
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds", null)
heldItemAttackSpeeds.forEach { (key, value) ->
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds.$key", value)
}
ocm.config.set("disabled_modules", disabledModules.filterNot { it == "disable-attack-cooldown" })
ocm.config.set("always_enabled_modules", alwaysEnabledModules.filterNot { it == "disable-attack-cooldown" })
val oldModeset =
ocm.config.getStringList("modesets.old").toMutableList().apply {
if (!contains("disable-attack-cooldown")) add("disable-attack-cooldown")
}
val newModeset =
ocm.config.getStringList("modesets.new").toMutableList().apply {
remove("disable-attack-cooldown")
}
ocm.config.set("modesets.old", oldModeset)
ocm.config.set("modesets.new", newModeset)
reloadAll()
block()
} finally {
ocm.config.set("disabled_modules", disabledModules)
ocm.config.set("always_enabled_modules", alwaysEnabledModules)
modesetSnapshot.forEach { (key, list) -> ocm.config.set("modesets.$key", list) }
ocm.config.set("disable-attack-cooldown.generic-attack-speed", genericSnapshot)
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds", null)
heldItemSnapshot.forEach { (key, value) ->
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds.$key", value)
}
reloadAll()
}
}
test("applies configured held-item attack speeds and falls back to the generic value on hotbar switch") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
spawned.player.inventory.heldItemSlot = 0
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync { switchHotbar(spawned.player, from = 0, to = 1) }
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("materials without an explicit held-item entry use disable-attack-cooldown.generic-attack-speed") {
withAttackCooldownConfig(
genericAttackSpeed = 13.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (13.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("world and modeset transitions restore vanilla 4.0 when disabled and reapply the held-item target when re-enabled") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val otherWorld = checkNotNull(Bukkit.getWorld("world_nether"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
setModeset(spawned.player, world, "old")
setModeset(spawned.player, otherWorld, "new")
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync {
setModeset(spawned.player, world, "new")
module?.onModesetChange(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (4.0 plusOrMinus 0.01)
runSync {
setModeset(spawned.player, world, "old")
module?.onModesetChange(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync {
spawned.player.teleport(Location(otherWorld, 0.0, 100.0, 0.0, 0f, 0f))
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(spawned.player, world))
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (4.0 plusOrMinus 0.01)
runSync {
spawned.player.teleport(Location(world, 0.0, 100.0, 0.0, 0f, 0f))
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(spawned.player, otherWorld))
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("user-added material keys are accepted when the running server recognises the material") {
val material = Material.matchMaterial("MACE") ?: return@test
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf(material.name to 7.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItemInMainHand(ItemStack(material))
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (7.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("main-hand attack speed follows hand swaps and uses the newly held item") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
runSync {
val swap =
PlayerSwapHandItemsEvent(
spawned.player,
spawned.player.inventory.itemInMainHand,
spawned.player.inventory.itemInOffHand,
)
Bukkit.getPluginManager().callEvent(swap)
spawned.player.inventory.setItemInMainHand(swap.offHandItem)
spawned.player.inventory.setItemInOffHand(swap.mainHandItem)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("cancelled hotbar changes keep attack speed tied to the actually held item") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
val canceller =
object : Listener {
@EventHandler(priority = EventPriority.LOWEST)
fun onHeld(event: PlayerItemHeldEvent) {
if (event.player.uniqueId == spawned.player.uniqueId) {
event.isCancelled = true
}
}
}
try {
runSync {
Bukkit.getPluginManager().registerEvents(canceller, testPlugin)
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
spawned.player.inventory.heldItemSlot = 0
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync {
val event = PlayerItemHeldEvent(spawned.player, 0, 1)
Bukkit.getPluginManager().callEvent(event)
}
runSync { spawned.player.inventory.heldItemSlot } shouldBe 0
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
} finally {
runSync { HandlerList.unregisterAll(canceller) }
cleanup(spawned)
}
}
}
test("cancelled hand swaps keep attack speed tied to the actual main-hand item") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
val canceller =
object : Listener {
@EventHandler(priority = EventPriority.LOWEST)
fun onSwap(event: PlayerSwapHandItemsEvent) {
if (event.player.uniqueId == spawned.player.uniqueId) {
event.isCancelled = true
}
}
}
try {
runSync {
Bukkit.getPluginManager().registerEvents(canceller, testPlugin)
spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
spawned.player.inventory.setItemInOffHand(ItemStack(Material.STICK))
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync {
val event =
PlayerSwapHandItemsEvent(
spawned.player,
spawned.player.inventory.itemInMainHand,
spawned.player.inventory.itemInOffHand,
)
Bukkit.getPluginManager().callEvent(event)
}
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
} finally {
runSync { HandlerList.unregisterAll(canceller) }
cleanup(spawned)
}
}
}
test("later inventory changes after a hotbar event do not trigger deferred attack-speed reconciliation") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
spawned.player.inventory.heldItemSlot = 0
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
runSync {
val event = PlayerItemHeldEvent(spawned.player, 0, 1)
Bukkit.getPluginManager().callEvent(event)
spawned.player.inventory.heldItemSlot = 1
spawned.player.inventory.heldItemSlot = 0
}
waitForPossibleDeferredWork()
runSync { spawned.player.inventory.heldItemSlot } shouldBe 0
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
test("unchanged post-swap inventory keeps the swap-applied attack speed without deferred re-checking") {
withAttackCooldownConfig(
genericAttackSpeed = 12.0,
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
) {
val world = checkNotNull(Bukkit.getWorld("world"))
val spawned = spawnFake(world)
try {
runSync {
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))
fireJoin(spawned.player)
}
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
runSync {
val event =
PlayerSwapHandItemsEvent(
spawned.player,
spawned.player.inventory.itemInMainHand,
spawned.player.inventory.itemInOffHand,
)
Bukkit.getPluginManager().callEvent(event)
spawned.player.inventory.setItemInMainHand(event.offHandItem)
spawned.player.inventory.setItemInOffHand(event.mainHandItem)
}
waitForPossibleDeferredWork()
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
} finally {
cleanup(spawned)
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackCooldownTrackerIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
import io.kotest.matchers.doubles.shouldBeLessThanOrEqual
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class AttackCooldownTrackerIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
}).get()
}
extensions(MainThreadDispatcherExtension(testPlugin))
test("attack cooldown tracking is only active where required") {
val isModern = Reflector.versionIsNewerOrEqualTo(1, 16, 0)
val uuid = runSync {
val world = Bukkit.getWorld("world") ?: error("world not loaded")
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
val fp = FakePlayer(testPlugin)
fp.spawn(location)
fp.uuid
}
try {
// Let at least one tick elapse so any scheduled tracker has a chance to populate.
delay(2 * 50L)
val last = runSync { AttackCooldownTracker.getLastCooldown(uuid) }
if (isModern) {
last shouldBe null
} else {
val value = (last ?: error("Expected a cached cooldown value on legacy servers")).toDouble()
value.shouldBeGreaterThanOrEqual(0.0)
value.shouldBeLessThanOrEqual(1.0)
}
} finally {
// Ensure we remove the fake player regardless of assertions.
runSync {
val player = Bukkit.getPlayer(uuid)
player?.let {
// FakePlayer removal fires a quit event; this is enough to validate map cleanup on legacy servers.
it.kickPlayer("test")
}
}
delay(2 * 50L)
// Legacy servers: tracker should remove on quit. Modern servers: tracker should remain inactive and return null.
val afterQuit = runSync { AttackCooldownTracker.getLastCooldown(uuid) }
afterQuit shouldBe null
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttackRangeIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeLessThan
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackRange
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.EntityType
import org.bukkit.entity.Player
import org.bukkit.entity.Zombie
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.event.player.PlayerSwapHandItemsEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
/**
* Behavioural reach tests for the attack-range module.
*
* The module extends melee reach; we assert a hit just outside vanilla reach succeeds
* when enabled, but the same swing misses when disabled. A control swing inside vanilla
* reach must hit in both cases.
*/
@OptIn(ExperimentalKotest::class)
class AttackRangeIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val attackRangeModule = ModuleLoader.getModules().filterIsInstance().firstOrNull()
val applyMethod =
attackRangeModule?.javaClass?.getDeclaredMethod("applyToHeld", Player::class.java)?.apply {
isAccessible = true
}
fun hasAttackRange(stack: ItemStack?): Boolean =
try {
if (stack == null) return false
val dctClass = Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
val typeField = dctClass.getField("ATTACK_RANGE")
val type = typeField.get(null)
val baseTypeClass = Class.forName("io.papermc.paper.datacomponent.DataComponentType")
val getter = ItemStack::class.java.getMethod("getData", baseTypeClass)
getter.invoke(stack, type) != null
} catch (t: Throwable) {
false
}
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
suspend fun withModuleState(
enabled: Boolean,
maxRange: Double = 6.0,
margin: Double = 0.1,
block: suspend () -> Unit,
) {
// Snapshot
val disabledOrig = ocm.config.getStringList("disabled_modules").toMutableList()
val alwaysOrig = ocm.config.getStringList("always_enabled_modules").toMutableList()
val maxOrig = ocm.config.getDouble("attack-range.max-range")
val marginOrig = ocm.config.getDouble("attack-range.hitbox-margin")
fun cachedSet(field: String): MutableSet {
val f = kernitus.plugin.OldCombatMechanics.utilities.Config::class.java.getDeclaredField(field)
f.isAccessible = true
@Suppress("UNCHECKED_CAST")
return f.get(null) as MutableSet
}
val cachedDisabledOrig = cachedSet("disabledModules").toSet()
val cachedAlwaysOrig = cachedSet("alwaysEnabledModules").toSet()
try {
// Update lists
val disabled = disabledOrig.toMutableList()
val always = alwaysOrig.toMutableList()
if (enabled) {
disabled.remove("attack-range")
if (!always.contains("attack-range")) always.add("attack-range")
} else {
if (!disabled.contains("attack-range")) disabled.add("attack-range")
always.remove("attack-range")
}
ocm.config.set("disabled_modules", disabled)
ocm.config.set("always_enabled_modules", always)
cachedSet("disabledModules").apply {
clear()
addAll(disabled.map { it.lowercase() })
}
cachedSet("alwaysEnabledModules").apply {
clear()
addAll(always.map { it.lowercase() })
}
// Config tweaks
ocm.config.set("attack-range.max-range", maxRange)
ocm.config.set("attack-range.hitbox-margin", margin)
attackRangeModule?.reload()
ModuleLoader.toggleModules()
block()
} finally {
// Restore
ocm.config.set("disabled_modules", disabledOrig)
ocm.config.set("always_enabled_modules", alwaysOrig)
ocm.config.set("attack-range.max-range", maxOrig)
ocm.config.set("attack-range.hitbox-margin", marginOrig)
cachedSet("disabledModules").apply {
clear()
addAll(cachedDisabledOrig)
}
cachedSet("alwaysEnabledModules").apply {
clear()
addAll(cachedAlwaysOrig)
}
attackRangeModule?.reload()
ModuleLoader.toggleModules()
}
}
data class Actors(
val fake: FakePlayer,
val player: Player,
val zombie: Zombie,
)
fun spawnActors(): Actors {
lateinit var fake: FakePlayer
lateinit var player: Player
lateinit var zombie: Zombie
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
fake = FakePlayer(testPlugin)
fake.spawn(Location(world, 0.0, 100.0, 0.0))
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
zombie = world.spawnEntity(Location(world, 0.0, 100.0, 0.0), EntityType.ZOMBIE) as Zombie
zombie.health = zombie.maxHealth
}
return Actors(fake, player, zombie)
}
fun cleanup(actors: Actors) {
runSync {
actors.zombie.remove()
}
runSync { actors.fake.removePlayer() }
}
fun faceEntity(
player: Player,
target: org.bukkit.entity.Entity,
) {
val eye = player.eyeLocation
val tgt = target.location.clone().add(0.0, target.height / 2.0, 0.0)
val dir = tgt.toVector().subtract(eye.toVector())
val yaw = Math.toDegrees(Math.atan2(-dir.x, dir.z)).toFloat()
val pitch = Math.toDegrees(-Math.atan2(dir.y, Math.hypot(dir.x, dir.z))).toFloat()
val newLoc = player.location.clone()
newLoc.yaw = yaw
newLoc.pitch = pitch
player.teleport(newLoc)
}
fun swingAt(
zombie: Zombie,
player: Player,
) {
runSync {
faceEntity(player, zombie)
player.attack(zombie)
}
}
test("extended reach hits just outside vanilla range when module enabled (Paper 1.21.11+)") {
if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test
if (attackRangeModule == null) return@test
withModuleState(enabled = true, maxRange = 5.0, margin = 0.1) {
val actors = spawnActors()
val (_, player, zombie) = actors
runSync { applyMethod?.invoke(attackRangeModule, player) }
runSync {
zombie.noDamageTicks = 0
zombie.health = zombie.maxHealth
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
}
var startHealth = zombie.health
swingAt(zombie, player)
runSync { zombie.health shouldBeLessThan startHealth }
cleanup(actors)
}
}
test("reach boost is removed after disabling while holding the same item (Paper 1.21.11+)") {
if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test
if (attackRangeModule == null) return@test
val actors = spawnActors()
val (_, player, zombie) = actors
withModuleState(enabled = true, maxRange = 5.5) {
runSync { applyMethod?.invoke(attackRangeModule, player) }
runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }
var start = zombie.health
swingAt(zombie, player) // should hit with extended reach
runSync { zombie.health shouldBeLessThan start }
withModuleState(enabled = false) {
runSync {
// trigger hand re-evaluation
Bukkit.getPluginManager().callEvent(
PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),
)
zombie.health = zombie.maxHealth
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
}
start = zombie.health
swingAt(zombie, player) // should now miss
runSync { zombie.health shouldBe start }
}
}
cleanup(actors)
}
test("dropped items do not retain reach boost (Paper 1.21.11+)") {
if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test
if (attackRangeModule == null) return@test
val actors = spawnActors()
val (_, player, zombie) = actors
withModuleState(enabled = true) {
runSync { applyMethod?.invoke(attackRangeModule, player) }
runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }
var start = zombie.health
swingAt(zombie, player) // extended reach hit
runSync { zombie.health shouldBeLessThan start }
val dropped: ItemStack =
run {
var item: ItemStack? = null
runSync {
val drop = player.world.dropItem(player.location, player.inventory.itemInMainHand.clone())
Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop))
item = drop.itemStack
drop.remove()
}
item!!
}
withModuleState(enabled = false) {
runSync {
player.inventory.setItemInMainHand(dropped)
Bukkit.getPluginManager().callEvent(
PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),
)
zombie.health = zombie.maxHealth
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
}
start = zombie.health
swingAt(zombie, player) // should miss because drop cleaned component
runSync { zombie.health shouldBe start }
}
}
cleanup(actors)
}
test("extended reach does not apply when module disabled; close swing still hits (Paper 1.21.11+)") {
if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) return@test
if (attackRangeModule == null) return@test
withModuleState(enabled = false) {
val actors = spawnActors()
val (_, player, zombie) = actors
// Far swing should miss when module disabled
runSync {
zombie.noDamageTicks = 0
zombie.health = zombie.maxHealth
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
}
var healthAfterFar = zombie.health
swingAt(zombie, player)
runSync { healthAfterFar = zombie.health }
// Control: move inside vanilla reach and ensure it hits
runSync {
zombie.noDamageTicks = 0
zombie.teleport(player.location.clone().add(0.0, 0.0, 2.5))
}
swingAt(zombie, player)
runSync {
zombie.health shouldBeLessThan healthAfterFar
}
cleanup(actors)
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/AttributeModifierCompat.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import org.bukkit.attribute.AttributeModifier
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.attribute.Attribute
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.ItemMeta
import com.google.common.collect.HashMultimap
import com.google.common.collect.Multimap
import java.util.UUID
fun createAttributeModifier(
name: String,
amount: Double,
operation: AttributeModifier.Operation,
slot: EquipmentSlot? = null,
uuid: UUID = UUID.randomUUID()
): AttributeModifier {
// Use the most specific constructor available at runtime.
if (slot != null) {
try {
@Suppress("DEPRECATION")
return AttributeModifier(uuid, name, amount, operation, slot)
} catch (e: NoSuchMethodError) {
// Fall back to the older signatures below.
}
}
try {
@Suppress("DEPRECATION")
return AttributeModifier(uuid, name, amount, operation)
} catch (e: NoSuchMethodError) {
@Suppress("DEPRECATION")
return AttributeModifier(name, amount, operation)
}
}
fun addAttributeModifierCompat(meta: ItemMeta, attribute: Attribute, modifier: AttributeModifier) {
try {
meta.addAttributeModifier(attribute, modifier)
return
} catch (e: NoSuchMethodError) {
// Older APIs do not expose addAttributeModifier on ItemMeta.
}
try {
val multimap = HashMultimap.create()
multimap.put(attribute, modifier)
meta.setAttributeModifiers(multimap)
} catch (e: NoSuchMethodError) {
// Attribute modifiers are not supported on this API version.
}
}
fun getDefaultAttributeModifiersCompat(
item: ItemStack,
slot: EquipmentSlot,
attribute: Attribute
): Collection {
try {
return item.type.getDefaultAttributeModifiers(slot)[attribute] ?: emptySet()
} catch (e: NoSuchMethodError) {
// Fall back to older Material APIs if present.
}
val modifiers = try {
val method = item.type.javaClass.getMethod("getAttributeModifiers", EquipmentSlot::class.java)
@Suppress("UNCHECKED_CAST")
val multimap = method.invoke(item.type, slot) as Multimap
multimap.get(attribute) ?: emptySet()
} catch (e: Exception) {
emptySet()
}
if (modifiers.isNotEmpty()) {
return modifiers
}
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
if (attackDamageAttribute != null && attribute == attackDamageAttribute && slot == EquipmentSlot.HAND) {
val fallbackDamage = NewWeaponDamage.getDamageOrNull(item.type) ?: return emptySet()
val amount = fallbackDamage.toDouble() - 1.0
val fallbackModifier = createAttributeModifier(
name = "ocm-fallback-damage",
amount = amount,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = slot
)
return setOf(fallbackModifier)
}
return emptySet()
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ChorusFruitIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.doubles.shouldBeLessThanOrEqual
import kernitus.plugin.OldCombatMechanics.module.ModuleChorusFruit
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.BlockFace
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerTeleportEvent
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ChorusFruitIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val chorusModule = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleChorusFruit not registered")
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun preparePlatform() {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
// Solid floor at y=100 and clear air above it in a 25x25 area around the origin
for (x in -12..12) {
for (z in -12..12) {
world.getBlockAt(x, 100, z).type = Material.STONE
for (y in 101..105) {
world.getBlockAt(x, y, z).type = Material.AIR
}
}
}
}
}
fun clearPlatform() {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
for (x in -12..12) {
for (z in -12..12) {
for (y in 99..105) {
world.getBlockAt(x, y, z).type = Material.AIR
}
}
}
}
}
suspend fun TestScope.withChorusConfig(distance: Double, block: suspend TestScope.() -> Unit) {
val enabled = ocm.config.getBoolean("chorus-fruit.enabled")
val maxDistance = ocm.config.getDouble("chorus-fruit.max-teleportation-distance")
val preventEating = ocm.config.getBoolean("chorus-fruit.prevent-eating")
val hungerValue = ocm.config.getInt("chorus-fruit.hunger-value")
val saturationValue = ocm.config.getDouble("chorus-fruit.saturation-value")
try {
ocm.config.set("chorus-fruit.enabled", true)
ocm.config.set("chorus-fruit.max-teleportation-distance", distance)
ocm.config.set("chorus-fruit.prevent-eating", false)
ocm.config.set("chorus-fruit.hunger-value", hungerValue)
ocm.config.set("chorus-fruit.saturation-value", saturationValue)
chorusModule.reload()
ModuleLoader.toggleModules()
block()
} finally {
clearPlatform()
ocm.config.set("chorus-fruit.enabled", enabled)
ocm.config.set("chorus-fruit.max-teleportation-distance", maxDistance)
ocm.config.set("chorus-fruit.prevent-eating", preventEating)
ocm.config.set("chorus-fruit.hunger-value", hungerValue)
ocm.config.set("chorus-fruit.saturation-value", saturationValue)
chorusModule.reload()
ModuleLoader.toggleModules()
}
}
fun Location.isSafe(): Boolean {
val feet = block
val head = feet.getRelative(BlockFace.UP)
val below = feet.getRelative(BlockFace.DOWN)
val legacy = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.versionIsNewerOrEqualTo(1, 13, 0)
val feetPassable = if (legacy) !feet.type.isSolid else feet.isPassable
val headPassable = if (legacy) !head.type.isSolid else head.isPassable
return feetPassable && headPassable && below.type.isSolid
}
suspend fun withFakePlayer(origin: Location, block: suspend (Player) -> Unit) {
lateinit var fake: FakePlayer
lateinit var player: Player
runSync {
fake = FakePlayer(testPlugin)
fake.spawn(origin)
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
data.setModesetForWorld(player.world.uid, "old")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)
}
try {
block(player)
} finally {
runSync {
fake.removePlayer()
}
}
}
test("chorus fruit custom distance teleports to a safe spot") {
preparePlatform()
withChorusConfig(distance = 1.0) {
val origin = Location(checkNotNull(Bukkit.getWorld("world")), 0.5, 101.0, 0.5)
withFakePlayer(origin) { player ->
var result: Location? = null
runSync {
val event = PlayerTeleportEvent(
player,
player.location,
player.location.clone(),
PlayerTeleportEvent.TeleportCause.CHORUS_FRUIT
)
Bukkit.getPluginManager().callEvent(event)
result = event.to
}
val target = result ?: error("Teleport target was null")
// Horizontal displacement should respect the configured radius
kotlin.math.abs(target.x - origin.x) shouldBeLessThanOrEqual 1.0
kotlin.math.abs(target.z - origin.z) shouldBeLessThanOrEqual 1.0
// Y stays within the search band (clamped to world height)
kotlin.math.abs(target.y - origin.y) shouldBeLessThanOrEqual 1.0
target.isSafe().shouldBeTrue()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ConfigMigrationIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldNotContain
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.Config
import org.bukkit.Bukkit
import org.bukkit.configuration.file.YamlConfiguration
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ConfigMigrationIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun withConfigFile(block: () -> Unit) {
val dataFolder = ocm.dataFolder
val configFile = File(dataFolder, "config.yml")
val backupFile = File(dataFolder, "config-backup.yml")
val originalConfig = if (configFile.exists()) configFile.readText() else ""
val hadBackup = backupFile.exists()
val originalBackup = if (hadBackup) backupFile.readText() else null
try {
block()
} finally {
if (!configFile.parentFile.exists()) {
configFile.parentFile.mkdirs()
}
configFile.writeText(originalConfig)
if (hadBackup) {
backupFile.writeText(originalBackup ?: "")
} else if (backupFile.exists()) {
backupFile.delete()
}
ocm.reloadConfig()
Config.reload()
}
}
test("config upgrade migrates module buckets and preserves modesets") {
runSync {
withConfigFile {
val configFile = File(ocm.dataFolder, "config.yml")
val oldConfig = YamlConfiguration.loadConfiguration(configFile)
val currentVersion = oldConfig.getInt("config-version")
val oldVersion = currentVersion - 1
oldConfig.set("config-version", oldVersion)
oldConfig.set("force-below-1-18-1-config-upgrade", true)
oldConfig.set(
"modesets",
mapOf(
"custom" to listOf("disable-offhand"),
"alt" to listOf("old-golden-apples")
)
)
oldConfig.set("worlds.world", listOf("custom", "alt"))
oldConfig.set("disable-offhand.enabled", false)
oldConfig.set("old-golden-apples.enabled", true)
oldConfig.set("old-potion-effects.enabled", true)
oldConfig.set("disable-attack-cooldown.enabled", false)
oldConfig.save(configFile)
Config.reload()
val upgradedConfig = ocm.config
upgradedConfig.getInt("config-version") shouldBe currentVersion
val alwaysEnabled = upgradedConfig.getStringList("always_enabled_modules")
val disabledModules = upgradedConfig.getStringList("disabled_modules")
alwaysEnabled.shouldContain("old-potion-effects")
disabledModules.shouldContain("disable-offhand")
disabledModules.shouldContain("disable-attack-cooldown")
disabledModules.shouldNotContain("old-golden-apples")
val modesetsSection = upgradedConfig.getConfigurationSection("modesets")
?: error("Modesets section missing after migration")
modesetsSection.getKeys(false).shouldContain("custom")
modesetsSection.getKeys(false).shouldContain("alt")
upgradedConfig.getStringList("modesets.custom").shouldNotContain("disable-offhand")
upgradedConfig.getStringList("modesets.alt").shouldContain("old-golden-apples")
upgradedConfig.getStringList("worlds.world") shouldBe listOf("custom", "alt")
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ConsumableComponentIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.BlockFace
import org.bukkit.entity.Player
import org.bukkit.event.block.Action
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.inventory.ClickType
import org.bukkit.event.inventory.InventoryAction
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.event.inventory.InventoryDragEvent
import org.bukkit.event.inventory.InventoryType
import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.event.player.PlayerSwapHandItemsEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.Optional
import java.util.UUID
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ConsumableComponentIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val swordBlocking =
ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModuleSwordBlocking not registered")
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(testPlugin, Callable { action() })
.get()
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
fun rightClickMainHand(player: Player) {
runSync {
val event =
PlayerInteractEvent(
player,
Action.RIGHT_CLICK_AIR,
player.inventory.itemInMainHand,
null,
BlockFace.SELF,
EquipmentSlot.HAND,
)
Bukkit.getPluginManager().callEvent(event)
}
}
fun paperDataComponentApiPresent(): Boolean =
try {
Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
true
} catch (_: Throwable) {
false
}
fun paperConsumablePathAvailable(): Boolean {
if (!paperDataComponentApiPresent()) return false
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull() ?: return false
return try {
val supportedField = ModuleSwordBlocking::class.java.getDeclaredField("paperSupported")
supportedField.isAccessible = true
val adapterField = ModuleSwordBlocking::class.java.getDeclaredField("paperAdapter")
adapterField.isAccessible = true
supportedField.getBoolean(module) && adapterField.get(module) != null
} catch (_: Throwable) {
false
}
}
fun packetEventsClass(name: String): Class<*> = Class.forName(name, true, ocm.javaClass.classLoader)
fun packetEventsClientVersionClass(): Class<*> =
packetEventsClass("kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.ClientVersion")
fun packetEventsUserClass(): Class<*> =
packetEventsClass("kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.User")
fun packetEventsUserProfileClass(): Class<*> =
packetEventsClass("kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.player.UserProfile")
fun packetEventsConnectionStateClass(): Class<*> =
packetEventsClass("kernitus.plugin.OldCombatMechanics.lib.packetevents.api.protocol.ConnectionState")
fun packetEventsApi(): Any {
val packetEventsClass =
packetEventsClass("kernitus.plugin.OldCombatMechanics.lib.packetevents.api.PacketEvents")
return packetEventsClass.getMethod("getAPI").invoke(null)
?: error("PacketEvents API not available")
}
fun packetEventsPlayerManager(): Any {
val api = packetEventsApi()
val method = api.javaClass.getDeclaredMethod("getPlayerManager")
method.isAccessible = true
return method.invoke(api)
?: error("PacketEvents PlayerManager not available")
}
fun packetEventsProtocolManager(): Any {
val api = packetEventsApi()
val method = api.javaClass.getDeclaredMethod("getProtocolManager")
method.isAccessible = true
return method.invoke(api)
?: error("PacketEvents ProtocolManager not available")
}
suspend fun requirePacketEventsUser(player: Player): Any {
val playerManager = packetEventsPlayerManager()
val getUserMethod = playerManager.javaClass.getDeclaredMethod("getUser", Any::class.java)
getUserMethod.isAccessible = true
repeat(10) {
val user = runSync { getUserMethod.invoke(playerManager, player) }
if (user != null) return user
delayTicks(1)
}
val getChannelMethod = playerManager.javaClass.getDeclaredMethod("getChannel", Any::class.java)
getChannelMethod.isAccessible = true
val channel =
runSync { getChannelMethod.invoke(playerManager, player) }
?: error("PacketEvents channel missing for ${player.name}")
val protocolManager = packetEventsProtocolManager()
val setChannel = protocolManager.javaClass.getMethod("setChannel", UUID::class.java, Any::class.java)
runSync { setChannel.invoke(protocolManager, player.uniqueId, channel) }
val connectionStateClass = packetEventsConnectionStateClass()
@Suppress("UNCHECKED_CAST")
val connectionStateEnum = connectionStateClass as Class>
val playState = java.lang.Enum.valueOf(connectionStateEnum, "PLAY")
val profileClass = packetEventsUserProfileClass()
val profile =
profileClass
.getConstructor(UUID::class.java, String::class.java)
.newInstance(player.uniqueId, player.name)
val clientVersionClass = packetEventsClientVersionClass()
@Suppress("UNCHECKED_CAST")
val clientVersionEnum = clientVersionClass as Class>
val defaultVersion = java.lang.Enum.valueOf(clientVersionEnum, "V_1_21_11")
val userClass = packetEventsUserClass()
val user =
userClass
.getConstructor(Any::class.java, connectionStateClass, clientVersionClass, profileClass)
.newInstance(channel, playState, defaultVersion, profile)
val setUser = protocolManager.javaClass.getMethod("setUser", Any::class.java, userClass)
runSync { setUser.invoke(protocolManager, channel, user) }
return user
}
fun packetEventsClientVersion(versionName: String): Any {
val versionClass = packetEventsClientVersionClass()
@Suppress("UNCHECKED_CAST")
val enumClass = versionClass as Class>
return java.lang.Enum.valueOf(enumClass, versionName)
}
fun unknownPacketEventsClientVersionName(): String? {
val versionClass = packetEventsClientVersionClass()
@Suppress("UNCHECKED_CAST")
val enumClass = versionClass as Class>
val names = enumClass.enumConstants.map { it.name }
return when {
names.contains("UNKNOWN") -> "UNKNOWN"
names.contains("HIGHER_THAN_SUPPORTED_VERSIONS") -> "HIGHER_THAN_SUPPORTED_VERSIONS"
else -> null
}
}
suspend fun withPacketEventsClientVersion(
player: Player,
versionName: String,
block: suspend () -> Unit,
) {
val user = requirePacketEventsUser(player)
val versionClass = packetEventsClientVersionClass()
val getVersion = user.javaClass.getDeclaredMethod("getClientVersion")
val setVersion = user.javaClass.getDeclaredMethod("setClientVersion", versionClass)
getVersion.isAccessible = true
setVersion.isAccessible = true
val original = runSync { getVersion.invoke(user) }
val target = packetEventsClientVersion(versionName)
runSync { setVersion.invoke(user, target) }
try {
block()
} finally {
if (original != null) {
runSync { setVersion.invoke(user, original) }
}
}
}
fun nmsItemStack(stack: ItemStack?): Any? {
if (stack == null) return null
var handle: Any? = null
try {
var type: Class<*>? = stack.javaClass
while (type != null && type != Any::class.java) {
val field =
try {
type.getDeclaredField("handle")
} catch (_: NoSuchFieldException) {
type = type.superclass
continue
}
field.isAccessible = true
handle = runCatching { field.get(stack) }.getOrNull()
break
}
} catch (_: Throwable) {
handle = null
}
if (handle != null) return handle
return try {
val craftItemStack = Class.forName("org.bukkit.craftbukkit.inventory.CraftItemStack")
val asNmsCopy = craftItemStack.getMethod("asNMSCopy", ItemStack::class.java)
asNmsCopy.invoke(null, stack)
} catch (t: Throwable) {
throw IllegalStateException(
"Failed to obtain NMS ItemStack (${t::class.java.simpleName}: ${t.message})",
t,
)
}
}
fun craftMirrorStack(type: Material): ItemStack {
val craftItemStack = Class.forName("org.bukkit.craftbukkit.inventory.CraftItemStack")
val nmsItemStackClass = Class.forName("net.minecraft.world.item.ItemStack")
val asNmsCopy = craftItemStack.getMethod("asNMSCopy", ItemStack::class.java)
val asCraftMirror = craftItemStack.getMethod("asCraftMirror", nmsItemStackClass)
val nms = asNmsCopy.invoke(null, ItemStack(type))
return asCraftMirror.invoke(null, nms) as ItemStack
}
fun consumablePatchEntry(stack: ItemStack?): Optional<*>? {
val nmsStack = nmsItemStack(stack) ?: return null
return try {
val patch = nmsStack.javaClass.getMethod("getComponentsPatch").invoke(nmsStack) ?: return null
val dataComponentType = Class.forName("net.minecraft.core.component.DataComponentType")
val dataComponents = Class.forName("net.minecraft.core.component.DataComponents")
val consumableType = dataComponents.getField("CONSUMABLE").get(null)
val getMethod = patch.javaClass.getMethod("get", dataComponentType)
getMethod.invoke(patch, consumableType) as? Optional<*>
} catch (t: Throwable) {
throw IllegalStateException(
"Failed to inspect data component patch (${t::class.java.simpleName}: ${t.message})",
t,
)
}
}
fun hasConsumableRemoval(stack: ItemStack?): Boolean {
val entry = consumablePatchEntry(stack) ?: return false
return !entry.isPresent
}
fun nmsConsumableType(): Any = Class.forName("net.minecraft.core.component.DataComponents").getField("CONSUMABLE").get(null)
fun nmsConsumableComponent(): Any {
val nmsConsumable = Class.forName("net.minecraft.world.item.component.Consumable")
val builder = nmsConsumable.getMethod("builder").invoke(null)
val nmsUseAnim = Class.forName("net.minecraft.world.item.ItemUseAnimation")
val blockAnim = nmsUseAnim.getField("BLOCK").get(null)
val withSeconds = builder.javaClass.getMethod("consumeSeconds", Float::class.javaPrimitiveType).invoke(builder, 1.6f)
val withAnim = withSeconds.javaClass.getMethod("animation", nmsUseAnim).invoke(withSeconds, blockAnim)
return withAnim.javaClass.getMethod("build").invoke(withAnim)
}
fun hasConsumableComponent(stack: ItemStack?): Boolean {
val nmsStack = nmsItemStack(stack) ?: return false
return try {
val dataComponentType = Class.forName("net.minecraft.core.component.DataComponentType")
val consumableType = nmsConsumableType()
val hasMethod = nmsStack.javaClass.getMethod("has", dataComponentType)
val result = hasMethod.invoke(nmsStack, consumableType)
result is Boolean && result
} catch (_: Throwable) {
false
}
}
fun applyConsumableComponent(stack: ItemStack?) {
val nmsStack = nmsItemStack(stack) ?: return
try {
val dataComponentType = Class.forName("net.minecraft.core.component.DataComponentType")
val consumableType = nmsConsumableType()
val consumableComponent = nmsConsumableComponent()
val setMethod =
nmsStack.javaClass.methods.firstOrNull { m ->
m.name == "set" &&
m.parameterCount == 2 &&
m.parameterTypes[0] == dataComponentType
} ?: error("NMS ItemStack#set(DataComponentType, value) not found")
setMethod.invoke(nmsStack, consumableType, consumableComponent)
} catch (t: Throwable) {
throw IllegalStateException(
"Failed to apply NMS consumable component (${t::class.java.simpleName}: ${t.message})",
t,
)
}
}
fun assertNoConsumableRemoval(
stack: ItemStack?,
label: String,
) {
val entry = consumablePatchEntry(stack)
if (entry != null && !entry.isPresent) {
error("$label gained !minecraft:consumable")
}
}
fun setModeset(
player: Player,
modeset: String?,
) {
val data = getPlayerData(player.uniqueId)
val worldId = player.world.uid
if (modeset == null) {
data.modesetByWorld.remove(worldId)
} else {
data.setModesetForWorld(worldId, modeset)
}
setPlayerData(player.uniqueId, data)
}
fun syntheticPlayerDeathEvent(
player: Player,
drops: MutableList,
): PlayerDeathEvent {
for (ctor in PlayerDeathEvent::class.java.constructors) {
val args = arrayOfNulls(ctor.parameterCount)
var supported = true
for ((index, paramType) in ctor.parameterTypes.withIndex()) {
val value: Any? =
when {
Player::class.java.isAssignableFrom(paramType) -> {
player
}
MutableList::class.java.isAssignableFrom(paramType) || List::class.java.isAssignableFrom(paramType) -> {
drops
}
paramType == Int::class.javaPrimitiveType || paramType == Int::class.java -> {
0
}
paramType == Boolean::class.javaPrimitiveType || paramType == Boolean::class.java -> {
false
}
paramType == String::class.java -> {
""
}
else -> {
if (paramType.isPrimitive) {
supported = false
null
} else {
null
}
}
}
if (!supported) break
args[index] = value
}
if (!supported) continue
val instance = runCatching { ctor.newInstance(*args) }.getOrNull() ?: continue
if (instance is PlayerDeathEvent) {
return instance
}
}
error("Failed to create PlayerDeathEvent reflectively")
}
fun snapshotSection(path: String): Any? {
val section = ocm.config.getConfigurationSection(path)
return section?.getValues(false) ?: ocm.config.get(path)
}
fun restoreSection(
path: String,
value: Any?,
) {
ocm.config.set(path, null)
when (value) {
null -> {
Unit
}
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
ocm.config.createSection(path, value as Map)
}
else -> {
ocm.config.set(path, value)
}
}
}
suspend fun withWorldModesets(
worldModesets: List,
block: suspend () -> Unit,
) {
val originalWorlds = runSync { snapshotSection("worlds") }
try {
runSync {
ocm.config.set("worlds.world", worldModesets)
ocm.saveConfig()
Config.reload()
}
block()
} finally {
runSync {
restoreSection("worlds", originalWorlds)
ocm.saveConfig()
Config.reload()
}
}
}
suspend fun withSwordBlockingDisabled(block: suspend () -> Unit) {
val originalAlways = runSync { snapshotSection("always_enabled_modules") }
val originalDisabled = runSync { snapshotSection("disabled_modules") }
val originalModesets = runSync { snapshotSection("modesets") }
try {
runSync {
val always =
ocm
.config
.getStringList("always_enabled_modules")
.filterNot { it.equals("sword-blocking", ignoreCase = true) }
ocm.config.set("always_enabled_modules", always)
val disabled =
ocm
.config
.getStringList("disabled_modules")
.filterNot { it.equals("sword-blocking", ignoreCase = true) }
.toMutableList()
disabled.add("sword-blocking")
ocm.config.set("disabled_modules", disabled)
val modesetsSection =
ocm.config.getConfigurationSection("modesets")
?: error("modesets missing")
for (key in modesetsSection.getKeys(false)) {
val modules =
modesetsSection
.getStringList(key)
.filterNot { it.equals("sword-blocking", ignoreCase = true) }
ocm.config.set("modesets.$key", modules)
}
ocm.saveConfig()
Config.reload()
}
block()
} finally {
runSync {
restoreSection("always_enabled_modules", originalAlways)
restoreSection("disabled_modules", originalDisabled)
restoreSection("modesets", originalModesets)
ocm.saveConfig()
Config.reload()
}
}
}
suspend fun withSwordBlockingPaperAnimation(
enabled: Boolean,
block: suspend () -> Unit,
) {
val original = runSync { snapshotSection("sword-blocking.paper-animation") }
try {
runSync {
ocm.config.set("sword-blocking.paper-animation", enabled)
ocm.saveConfig()
Config.reload()
}
block()
} finally {
runSync {
restoreSection("sword-blocking.paper-animation", original)
ocm.saveConfig()
Config.reload()
}
}
}
lateinit var fake: FakePlayer
lateinit var player: Player
beforeSpec {
runSync {
val world = Bukkit.getWorld("world") ?: error("world missing")
fake = FakePlayer(testPlugin)
fake.spawn(Location(world, 0.0, 100.0, 0.0))
player = Bukkit.getPlayer(fake.uuid) ?: error("player missing")
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
player.updateInventory()
}
}
afterSpec {
runSync {
fake.removePlayer()
}
}
beforeTest {
runSync {
setModeset(player, "old")
player.inventory.clear()
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
player.setItemOnCursor(ItemStack(Material.AIR))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
}
test("hotbar swap keeps food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
player.inventory.setItem(0, ItemStack(Material.BREAD))
player.inventory.setItem(1, ItemStack(Material.STONE))
player.inventory.heldItemSlot = 0
}
val before = runSync { player.inventory.getItem(0) }
assertNoConsumableRemoval(before, "hotbar food (before)")
runSync {
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1))
}
val after = runSync { player.inventory.getItem(0) }
hasConsumableRemoval(after) shouldBe false
}
test("inventory click keeps slot and cursor food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val view =
runSync { player.openInventory(player.inventory) }
?: error("inventory view missing")
try {
runSync {
player.inventory.setItem(0, ItemStack(Material.BREAD))
player.setItemOnCursor(ItemStack(Material.CARROT))
}
val slotItem = runSync { player.inventory.getItem(0) }
val cursorItem = runSync { player.itemOnCursor }
assertNoConsumableRemoval(slotItem, "slot food (before)")
assertNoConsumableRemoval(cursorItem, "cursor food (before)")
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
click.currentItem = slotItem
click.cursor = cursorItem
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
delayTicks(1)
val afterSlot = runSync { player.inventory.getItem(0) }
val afterCursor = runSync { player.itemOnCursor }
hasConsumableRemoval(afterSlot) shouldBe false
hasConsumableRemoval(afterCursor) shouldBe false
} finally {
runSync { player.closeInventory() }
}
}
test("inventory click does not alter swords when no consumable change is needed") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItem(1, ItemStack(Material.STONE))
player.inventory.heldItemSlot = 1
player.setItemOnCursor(ItemStack(Material.IRON_SWORD))
}
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }
val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }
applyConsumableComponent(slotItem)
applyConsumableComponent(cursorItem)
assertNoConsumableRemoval(slotItem, "slot sword (before)")
assertNoConsumableRemoval(cursorItem, "cursor sword (before)")
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
click.currentItem = slotItem
click.cursor = cursorItem
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
delayTicks(1)
val afterSlot = runSync { player.inventory.getItem(0) }
val afterCursor = runSync { player.itemOnCursor }
hasConsumableRemoval(afterSlot) shouldBe false
hasConsumableRemoval(afterCursor) shouldBe false
} finally {
runSync { player.closeInventory() }
}
}
test("stale deferred click reapply does not taint newly selected main-hand sword") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
runSync {
hasConsumableComponent(player.inventory.getItem(0)) shouldBe false
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val click =
runSync {
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.NUMBER_KEY,
InventoryAction.HOTBAR_SWAP,
0,
)
}
runSync { Bukkit.getPluginManager().callEvent(click) }
// Change selection directly before deferred next-tick reapply runs.
runSync {
player.inventory.heldItemSlot = 1
player.updateInventory()
}
delayTicks(1)
runSync {
player.inventory.heldItemSlot shouldBe 1
player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
} finally {
runSync { player.closeInventory() }
}
}
test("inventory click does not strip consumable component when sword-blocking disabled for player modeset") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "new")
}
runSync {
swordBlocking.isEnabled(player) shouldBe false
}
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }
val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }
applyConsumableComponent(slotItem)
applyConsumableComponent(cursorItem)
assertNoConsumableRemoval(slotItem, "slot sword (before)")
assertNoConsumableRemoval(cursorItem, "cursor sword (before)")
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
click.currentItem = slotItem
click.cursor = cursorItem
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
// Assert the same objects we supplied to the event were not mutated.
hasConsumableComponent(slotItem) shouldBe true
hasConsumableComponent(cursorItem) shouldBe true
} finally {
runSync { player.closeInventory() }
}
}
test("inventory click does not strip consumable component when sword-blocking disabled in world defaults") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
withWorldModesets(listOf("new")) {
runSync {
setModeset(player, null)
}
runSync {
swordBlocking.isEnabled(player) shouldBe false
}
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val slotItem = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }
val cursorItem = runSync { craftMirrorStack(Material.IRON_SWORD) }
applyConsumableComponent(slotItem)
applyConsumableComponent(cursorItem)
assertNoConsumableRemoval(slotItem, "slot sword (before)")
assertNoConsumableRemoval(cursorItem, "cursor sword (before)")
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
click.currentItem = slotItem
click.cursor = cursorItem
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
// Assert the same objects we supplied to the event were not mutated.
hasConsumableComponent(slotItem) shouldBe true
hasConsumableComponent(cursorItem) shouldBe true
} finally {
runSync { player.closeInventory() }
}
}
}
test("modeset change to disabled strips sword consumable component from hand") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))
}
// Seed the component on the actual hand item.
runSync {
val main = player.inventory.itemInMainHand
applyConsumableComponent(main)
player.inventory.setItemInMainHand(main)
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
// Change modeset so sword-blocking is disabled for this player.
runSync {
setModeset(player, "new")
swordBlocking.isEnabled(player) shouldBe false
// Simulate the plugin's modeset-change hook.
ModuleLoader.getModules().forEach { it.onModesetChange(player) }
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
test("disabled_modules clears sword consumable component after reload") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))
}
runSync {
val main = player.inventory.itemInMainHand
applyConsumableComponent(main)
player.inventory.setItemInMainHand(main)
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
withSwordBlockingDisabled {
runSync {
swordBlocking.isEnabled(player) shouldBe false
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("disabled_modules prevents sword consumable component on right-click") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
}
withSwordBlockingDisabled {
runSync {
swordBlocking.isEnabled(player) shouldBe false
}
rightClickMainHand(player)
delayTicks(1)
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("reload toggle disables and re-enables sword consumable component") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
}
rightClickMainHand(player)
delayTicks(1)
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
withSwordBlockingDisabled {
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
delayTicks(1)
rightClickMainHand(player)
delayTicks(1)
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
}
test("paper-animation config false clears stale sword consumable component after reload") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))
}
runSync {
val main = player.inventory.itemInMainHand
applyConsumableComponent(main)
player.inventory.setItemInMainHand(main)
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
withSwordBlockingPaperAnimation(false) {
runSync {
swordBlocking.isEnabled(player) shouldBe true
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("paper-animation config false prevents swap lifecycle from re-applying sword consumable component") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
withSwordBlockingPaperAnimation(false) {
val event =
runSync {
PlayerSwapHandItemsEvent(
player,
player.inventory.itemInMainHand.clone(),
player.inventory.itemInOffHand.clone(),
)
}
runSync { Bukkit.getPluginManager().callEvent(event) }
delayTicks(1)
runSync {
swordBlocking.isEnabled(player) shouldBe true
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("disabled_modules clears stored sword consumable components after reload") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.STONE))
}
runSync {
val stored = craftMirrorStack(Material.IRON_SWORD)
applyConsumableComponent(stored)
player.inventory.setItem(2, stored)
}
runSync {
hasConsumableComponent(player.inventory.getItem(2)) shouldBe true
}
withSwordBlockingDisabled {
runSync {
hasConsumableComponent(player.inventory.getItem(2)) shouldBe false
}
}
}
test("disabled_modules keeps offhand unchanged on right-click") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
}
val offhandBefore = runSync { player.inventory.itemInOffHand }
assertNoConsumableRemoval(offhandBefore, "offhand item (before disabled right-click)")
withSwordBlockingDisabled {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.APPLE
hasConsumableRemoval(player.inventory.itemInOffHand) shouldBe false
}
}
}
test("old client uses offhand shield instead of consumable animation") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("unknown client version uses offhand shield fallback instead of consumable animation") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val unknownVersion =
runCatching { unknownPacketEventsClientVersionName() }.getOrNull()
?: run {
println("Skipping: PacketEvents unknown client version enum constant unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
withPacketEventsClientVersion(player, unknownVersion) {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("missing PacketEvents client-version resolver fails safe to offhand shield fallback") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
val moduleClass = ModuleSwordBlocking::class.java
val packetEventsGetClientVersionField = moduleClass.getDeclaredField("packetEventsGetClientVersion")
val minClientVersionField = moduleClass.getDeclaredField("minClientVersion")
packetEventsGetClientVersionField.isAccessible = true
minClientVersionField.isAccessible = true
val originalGetClientVersion = runSync { packetEventsGetClientVersionField.get(swordBlocking) }
val originalMinClientVersion = runSync { minClientVersionField.get(swordBlocking) }
try {
runSync {
packetEventsGetClientVersionField.set(swordBlocking, null)
if (minClientVersionField.get(swordBlocking) == null) {
minClientVersionField.set(swordBlocking, packetEventsClientVersion("V_1_20_5"))
}
}
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
} finally {
runSync {
packetEventsGetClientVersionField.set(swordBlocking, originalGetClientVersion)
minClientVersionField.set(swordBlocking, originalMinClientVersion)
}
}
}
test("middle-click in custom GUI does not mutate held sword components") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
val gui = runSync { Bukkit.createInventory(null, 9, "OCM Test GUI") }
runSync {
gui.setItem(0, ItemStack(Material.STONE))
}
val view = runSync { player.openInventory(gui) } ?: error("inventory view missing")
try {
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.MIDDLE,
InventoryAction.CLONE_STACK,
)
click.currentItem = gui.getItem(0)
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
delayTicks(1)
runSync {
event.isCancelled shouldBe false
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
} finally {
runSync { player.closeInventory() }
}
}
test("inventory drag in custom GUI does not rewrite top-inventory swords") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.STONE))
}
val gui = runSync { Bukkit.createInventory(null, 9, "OCM Drag GUI") }
val topSword = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }
applyConsumableComponent(topSword)
runSync {
hasConsumableComponent(topSword) shouldBe true
gui.setItem(0, topSword)
}
val view = runSync { player.openInventory(gui) } ?: error("inventory view missing")
try {
val drag =
runSync {
InventoryDragEvent(
view,
ItemStack(Material.CARROT),
ItemStack(Material.CARROT),
false,
mapOf(0 to ItemStack(Material.CARROT)),
)
}
runSync { Bukkit.getPluginManager().callEvent(drag) }
delayTicks(1)
val afterTop = runSync { gui.getItem(0) }
runSync {
drag.isCancelled shouldBe false
hasConsumableComponent(afterTop) shouldBe true
}
} finally {
runSync { player.closeInventory() }
}
}
test("stale deferred drag reapply does not taint newly selected main-hand sword") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
runSync {
hasConsumableComponent(player.inventory.getItem(0)) shouldBe false
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
val gui = runSync { Bukkit.createInventory(null, 9, "OCM Drag Stale Slot") }
val view = runSync { player.openInventory(gui) } ?: error("inventory view missing")
try {
val drag =
runSync {
InventoryDragEvent(
view,
ItemStack(Material.CARROT),
ItemStack(Material.CARROT),
false,
mapOf(9 to ItemStack(Material.CARROT)),
)
}
runSync { Bukkit.getPluginManager().callEvent(drag) }
// Move to a different held slot before deferred next-tick reapply runs.
runSync {
player.inventory.heldItemSlot = 1
player.updateInventory()
}
delayTicks(1)
runSync {
player.inventory.heldItemSlot shouldBe 1
player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
} finally {
runSync { player.closeInventory() }
}
}
test("stale deferred drag context does not mutate dragged bottom slot") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
val gui = runSync { Bukkit.createInventory(null, 9, "OCM Drag Cleanup Stale") }
val view = runSync { player.openInventory(gui) } ?: error("inventory view missing")
try {
val draggedSword = runSync { craftMirrorStack(Material.DIAMOND_SWORD) }
applyConsumableComponent(draggedSword)
runSync {
hasConsumableComponent(draggedSword) shouldBe true
view.setItem(9, draggedSword)
}
val drag =
runSync {
InventoryDragEvent(
view,
ItemStack(Material.CARROT),
ItemStack(Material.CARROT),
false,
mapOf(9 to ItemStack(Material.CARROT)),
)
}
runSync { Bukkit.getPluginManager().callEvent(drag) }
// Move to a different held slot before deferred next-tick reapply runs.
runSync {
player.inventory.heldItemSlot = 1
player.updateInventory()
}
delayTicks(1)
val slotAfter = runSync { view.getItem(9) }
runSync {
player.inventory.heldItemSlot shouldBe 1
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
hasConsumableComponent(slotAfter) shouldBe true
}
} finally {
runSync { player.closeInventory() }
}
}
test("stale deferred drag reapply no-ops after inventory view change") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
val firstGui = runSync { Bukkit.createInventory(null, 9, "OCM Drag First View") }
val firstView = runSync { player.openInventory(firstGui) } ?: error("first inventory view missing")
try {
val drag =
runSync {
InventoryDragEvent(
firstView,
ItemStack(Material.CARROT),
ItemStack(Material.CARROT),
false,
mapOf(9 to ItemStack(Material.CARROT)),
)
}
runSync { Bukkit.getPluginManager().callEvent(drag) }
val secondGui = runSync { Bukkit.createInventory(null, 9, "OCM Drag Second View") }
runSync {
player.openInventory(secondGui)
}
delayTicks(1)
runSync {
player.openInventory.topInventory shouldBe secondGui
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
} finally {
runSync { player.closeInventory() }
}
}
test("old client shield fallback restores offhand item on hotbar change") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItem(0, ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItem(1, ItemStack(Material.STONE))
player.inventory.heldItemSlot = 0
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
}
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
runSync {
player.inventory.heldItemSlot = 1
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1))
}
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.APPLE
}
}
}
test("legacy fallback does not cancel custom GUI shield-icon clicks") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
val gui = runSync { Bukkit.createInventory(null, 9, "Shield GUI") }
runSync {
gui.setItem(0, ItemStack(Material.SHIELD))
}
val view = runSync { player.openInventory(gui) } ?: error("inventory view missing")
try {
val click =
runSync {
val event =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
event.currentItem = gui.getItem(0)
event
}
runSync { Bukkit.getPluginManager().callEvent(click) }
delayTicks(1)
runSync {
click.isCancelled shouldBe false
}
} finally {
runSync { player.closeInventory() }
}
}
}
test("legacy fallback does not cancel dropping unrelated shield items") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
val dropped =
runSync {
player.world.dropItem(
player.location,
ItemStack(Material.SHIELD).apply {
itemMeta = itemMeta?.apply { setDisplayName("Unrelated Shield") }
},
)
}
try {
val dropEvent = runSync { PlayerDropItemEvent(player, dropped) }
runSync { Bukkit.getPluginManager().callEvent(dropEvent) }
runSync {
dropEvent.isCancelled shouldBe false
}
} finally {
runSync { dropped.remove() }
}
}
}
test("legacy fallback still blocks swapping temporary offhand shield") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
}
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
val swapEvent =
runSync {
PlayerSwapHandItemsEvent(
player,
player.inventory.itemInMainHand,
player.inventory.itemInOffHand,
)
}
runSync { Bukkit.getPluginManager().callEvent(swapEvent) }
runSync {
swapEvent.isCancelled shouldBe true
}
}
}
test("legacy death replaces only temporary shield drop and clears legacy state once") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
player.updateInventory()
}
val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField("storedItems")
val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField("legacyStates")
storedItemsField.isAccessible = true
legacyStatesField.isAccessible = true
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
@Suppress("UNCHECKED_CAST")
val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap }
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
storedItems.containsKey(player.uniqueId) shouldBe true
val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>
legacyStates.containsKey(player.uniqueId) shouldBe true
}
val drops =
mutableListOf(
ItemStack(Material.SHIELD),
ItemStack(Material.SHIELD),
)
val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }
runSync { Bukkit.getPluginManager().callEvent(deathEvent) }
@Suppress("UNCHECKED_CAST")
val dropsAfter = runSync { (deathEvent.drops as MutableList).toList() }
runSync {
dropsAfter.any { it == null } shouldBe false
dropsAfter.count { it?.type == Material.APPLE } shouldBe 1
dropsAfter.count { it?.type == Material.SHIELD } shouldBe 1
storedItems.containsKey(player.uniqueId) shouldBe false
val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>
legacyStates.containsKey(player.uniqueId) shouldBe false
}
}
}
test("legacy death with keepInventory does not rewrite drops and still clears state") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
player.updateInventory()
}
val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField("storedItems")
val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField("legacyStates")
storedItemsField.isAccessible = true
legacyStatesField.isAccessible = true
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
@Suppress("UNCHECKED_CAST")
val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap }
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
storedItems.containsKey(player.uniqueId) shouldBe true
}
val drops = mutableListOf(ItemStack(Material.SHIELD), ItemStack(Material.STONE))
val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }
runSync {
deathEvent.keepInventory = true
}
runSync { Bukkit.getPluginManager().callEvent(deathEvent) }
@Suppress("UNCHECKED_CAST")
val dropsAfter = runSync { (deathEvent.drops as MutableList).toList() }
runSync {
dropsAfter.map { it?.type } shouldBe listOf(Material.SHIELD, Material.STONE)
player.inventory.itemInOffHand.type shouldBe Material.APPLE
storedItems.containsKey(player.uniqueId) shouldBe false
val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>
legacyStates.containsKey(player.uniqueId) shouldBe false
}
}
}
test("paper-animation swap restores stale stored offhand item before clearing legacy state") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.SHIELD))
player.updateInventory()
}
val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField("storedItems")
val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField("legacyStates")
storedItemsField.isAccessible = true
legacyStatesField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val storedItems = storedItemsField.get(swordBlocking) as MutableMap
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
storedItems[player.uniqueId] = ItemStack(Material.APPLE)
}
val swapEvent =
runSync {
PlayerSwapHandItemsEvent(
player,
player.inventory.itemInMainHand,
player.inventory.itemInOffHand,
)
}
runSync { Bukkit.getPluginManager().callEvent(swapEvent) }
runSync {
swapEvent.isCancelled shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.APPLE
storedItems.containsKey(player.uniqueId) shouldBe false
val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>
legacyStates.containsKey(player.uniqueId) shouldBe false
}
}
}
test("modeset change after disabled reload does not reapply sword consumable component") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(craftMirrorStack(Material.DIAMOND_SWORD))
}
runSync {
val main = player.inventory.itemInMainHand
applyConsumableComponent(main)
player.inventory.setItemInMainHand(main)
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
}
withSwordBlockingDisabled {
runSync {
setModeset(player, "new")
ModuleLoader.getModules().forEach { it.onModesetChange(player) }
}
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
runSync {
setModeset(player, "old")
ModuleLoader.getModules().forEach { it.onModesetChange(player) }
}
runSync {
swordBlocking.isEnabled(player) shouldBe false
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
}
}
}
test("number-key hotbar swap keeps food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val view =
runSync { player.openInventory(player.inventory) }
?: error("inventory view missing")
try {
runSync {
player.inventory.setItem(0, ItemStack(Material.STONE))
player.inventory.setItem(2, ItemStack(Material.BREAD))
}
val hotbarItem = runSync { player.inventory.getItem(2) }
assertNoConsumableRemoval(hotbarItem, "hotbar button food (before)")
val event =
runSync {
val click =
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.NUMBER_KEY,
InventoryAction.HOTBAR_SWAP,
2,
)
click.currentItem = player.inventory.getItem(0)
click
}
runSync { Bukkit.getPluginManager().callEvent(event) }
delayTicks(1)
val after = runSync { player.inventory.getItem(2) }
hasConsumableRemoval(after) shouldBe false
} finally {
runSync { player.closeInventory() }
}
}
test("stale deferred swap reapply does not taint newly selected main-hand sword") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val slot0Sword = craftMirrorStack(Material.DIAMOND_SWORD)
applyConsumableComponent(slot0Sword)
hasConsumableComponent(slot0Sword) shouldBe true
player.inventory.setItem(0, slot0Sword)
player.inventory.setItem(1, ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.STICK))
player.inventory.heldItemSlot = 0
player.updateInventory()
}
runSync {
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
val event =
runSync {
PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)
}
runSync { Bukkit.getPluginManager().callEvent(event) }
runSync {
val main = player.inventory.itemInMainHand
val off = player.inventory.itemInOffHand
player.inventory.setItemInMainHand(off)
player.inventory.setItemInOffHand(main)
}
// Change held slot directly before deferred next-tick swap handling runs.
runSync {
player.inventory.heldItemSlot = 1
player.updateInventory()
}
delayTicks(1)
runSync {
player.inventory.heldItemSlot shouldBe 1
player.inventory.itemInMainHand.type shouldBe Material.IRON_SWORD
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.DIAMOND_SWORD
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false
}
}
test("stale deferred swap reapply no-ops when inventory view changes, while offhand cleanup still runs") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val mainSword = craftMirrorStack(Material.DIAMOND_SWORD)
applyConsumableComponent(mainSword)
hasConsumableComponent(mainSword) shouldBe true
player.inventory.setItem(0, mainSword)
val offhandSword = craftMirrorStack(Material.IRON_SWORD)
applyConsumableComponent(offhandSword)
hasConsumableComponent(offhandSword) shouldBe true
player.inventory.setItemInOffHand(offhandSword)
player.inventory.heldItemSlot = 0
player.updateInventory()
}
val firstGui = runSync { Bukkit.createInventory(null, 9, "OCM Swap First View") }
runSync {
player.openInventory(firstGui)
}
val swapEvent =
runSync {
PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)
}
runSync { Bukkit.getPluginManager().callEvent(swapEvent) }
runSync {
val main = player.inventory.itemInMainHand
val off = player.inventory.itemInOffHand
player.inventory.setItemInMainHand(off)
player.inventory.setItemInOffHand(main)
}
// Keep the same held slot but change the open view before deferred task runs.
val secondGui = runSync { Bukkit.createInventory(null, 9, "OCM Swap Second View") }
runSync {
player.inventory.setItem(0, ItemStack(Material.GOLDEN_SWORD))
player.openInventory(secondGui)
player.updateInventory()
}
delayTicks(1)
runSync {
player.openInventory.topInventory shouldBe secondGui
player.inventory.heldItemSlot shouldBe 0
player.inventory.itemInMainHand.type shouldBe Material.GOLDEN_SWORD
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.DIAMOND_SWORD
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false
}
runSync { player.closeInventory() }
}
test("swap hand keeps food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.STONE))
player.inventory.setItemInOffHand(ItemStack(Material.BREAD))
}
val offhandBefore = runSync { player.inventory.itemInOffHand }
assertNoConsumableRemoval(offhandBefore, "offhand food (before)")
val event =
runSync {
PlayerSwapHandItemsEvent(player, player.inventory.itemInMainHand, player.inventory.itemInOffHand)
}
runSync { Bukkit.getPluginManager().callEvent(event) }
runSync {
val main = player.inventory.itemInMainHand
val off = player.inventory.itemInOffHand
player.inventory.setItemInMainHand(off)
player.inventory.setItemInOffHand(main)
}
delayTicks(1)
val mainAfter = runSync { player.inventory.itemInMainHand }
val offAfter = runSync { player.inventory.itemInOffHand }
val foodAfter = if (mainAfter.type == Material.BREAD) mainAfter else offAfter
hasConsumableRemoval(foodAfter) shouldBe false
}
test("dropping food keeps consumable component") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val drop =
runSync {
player.world.dropItem(player.location, ItemStack(Material.BREAD))
}
try {
val before = runSync { drop.itemStack }
assertNoConsumableRemoval(before, "dropped food (before)")
runSync { Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop)) }
val after = runSync { drop.itemStack }
hasConsumableRemoval(after) shouldBe false
} finally {
runSync { drop.remove() }
}
}
test("world change keeps hand food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.BREAD))
player.inventory.setItemInOffHand(ItemStack(Material.CARROT))
}
val beforeMain = runSync { player.inventory.itemInMainHand }
val beforeOff = runSync { player.inventory.itemInOffHand }
assertNoConsumableRemoval(beforeMain, "main hand food (before world change)")
assertNoConsumableRemoval(beforeOff, "offhand food (before world change)")
runSync { Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(player, player.world)) }
val afterMain = runSync { player.inventory.itemInMainHand }
val afterOff = runSync { player.inventory.itemInOffHand }
hasConsumableRemoval(afterMain) shouldBe false
hasConsumableRemoval(afterOff) shouldBe false
}
test("quit event keeps hand food consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.BREAD))
player.inventory.setItemInOffHand(ItemStack(Material.CARROT))
}
val beforeMain = runSync { player.inventory.itemInMainHand }
val beforeOff = runSync { player.inventory.itemInOffHand }
assertNoConsumableRemoval(beforeMain, "main hand food (before quit)")
assertNoConsumableRemoval(beforeOff, "offhand food (before quit)")
runSync { Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, "test")) }
val afterMain = runSync { player.inventory.itemInMainHand }
val afterOff = runSync { player.inventory.itemInOffHand }
hasConsumableRemoval(afterMain) shouldBe false
hasConsumableRemoval(afterOff) shouldBe false
}
test("world change strips consumable component from hand and stored swords") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val main = craftMirrorStack(Material.DIAMOND_SWORD)
val off = craftMirrorStack(Material.IRON_SWORD)
val stored = craftMirrorStack(Material.GOLDEN_SWORD)
applyConsumableComponent(main)
applyConsumableComponent(off)
applyConsumableComponent(stored)
player.inventory.setItemInMainHand(main)
player.inventory.setItemInOffHand(off)
player.inventory.setItem(2, stored)
player.updateInventory()
}
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true
hasConsumableComponent(player.inventory.getItem(2)) shouldBe true
}
runSync { Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(player, player.world)) }
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false
hasConsumableComponent(player.inventory.getItem(2)) shouldBe false
}
}
}
test("dropping sword strips consumable component from dropped stack") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val drop =
runSync {
val droppedSword = craftMirrorStack(Material.DIAMOND_SWORD)
applyConsumableComponent(droppedSword)
player.world.dropItem(player.location, droppedSword)
}
try {
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
hasConsumableComponent(drop.itemStack) shouldBe true
}
runSync { Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop)) }
runSync {
hasConsumableComponent(drop.itemStack) shouldBe false
}
}
} finally {
runSync { drop.remove() }
}
}
test("death event strips consumable component from sword drops") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
val drops = mutableListOf(runSync { craftMirrorStack(Material.DIAMOND_SWORD) })
runSync {
applyConsumableComponent(drops[0])
hasConsumableComponent(drops[0]) shouldBe true
}
withPacketEventsClientVersion(player, "V_1_21_11") {
val deathEvent = runSync { syntheticPlayerDeathEvent(player, drops) }
runSync { Bukkit.getPluginManager().callEvent(deathEvent) }
runSync {
hasConsumableComponent(drops[0]) shouldBe false
}
}
}
test("held-slot transition strips previous sword and applies new sword consumable") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val previous = craftMirrorStack(Material.DIAMOND_SWORD)
val next = craftMirrorStack(Material.IRON_SWORD)
applyConsumableComponent(previous)
player.inventory.setItem(0, previous)
player.inventory.setItem(1, next)
player.inventory.heldItemSlot = 0
player.updateInventory()
}
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
hasConsumableComponent(player.inventory.getItem(0)) shouldBe true
hasConsumableComponent(player.inventory.getItem(1)) shouldBe false
}
runSync { Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, 0, 1)) }
runSync {
hasConsumableComponent(player.inventory.getItem(0)) shouldBe false
hasConsumableComponent(player.inventory.getItem(1)) shouldBe true
}
}
}
test("quit event strips consumable component from hand and stored swords") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val main = craftMirrorStack(Material.DIAMOND_SWORD)
val off = craftMirrorStack(Material.IRON_SWORD)
val stored = craftMirrorStack(Material.GOLDEN_SWORD)
applyConsumableComponent(main)
applyConsumableComponent(off)
applyConsumableComponent(stored)
player.inventory.setItemInMainHand(main)
player.inventory.setItemInOffHand(off)
player.inventory.setItem(2, stored)
player.updateInventory()
}
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true
hasConsumableComponent(player.inventory.getItem(2)) shouldBe true
}
runSync { Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, "test")) }
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false
hasConsumableComponent(player.inventory.getItem(2)) shouldBe false
}
}
}
test("join event strips consumable component from hand and stored swords") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
val main = craftMirrorStack(Material.DIAMOND_SWORD)
val off = craftMirrorStack(Material.IRON_SWORD)
val stored = craftMirrorStack(Material.GOLDEN_SWORD)
applyConsumableComponent(main)
applyConsumableComponent(off)
applyConsumableComponent(stored)
player.inventory.setItemInMainHand(main)
player.inventory.setItemInOffHand(off)
player.inventory.setItem(2, stored)
player.updateInventory()
}
withPacketEventsClientVersion(player, "V_1_21_11") {
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe true
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe true
hasConsumableComponent(player.inventory.getItem(2)) shouldBe true
}
runSync { Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, "test")) }
runSync {
hasConsumableComponent(player.inventory.itemInMainHand) shouldBe false
hasConsumableComponent(player.inventory.itemInOffHand) shouldBe false
hasConsumableComponent(player.inventory.getItem(2)) shouldBe false
}
}
}
test("modeset disable clears stale legacy fallback shield state") {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
return@test
}
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.APPLE))
player.updateInventory()
}
val storedItemsField = ModuleSwordBlocking::class.java.getDeclaredField("storedItems")
val legacyStatesField = ModuleSwordBlocking::class.java.getDeclaredField("legacyStates")
storedItemsField.isAccessible = true
legacyStatesField.isAccessible = true
withPacketEventsClientVersion(player, "V_1_20_3") {
rightClickMainHand(player)
delayTicks(1)
@Suppress("UNCHECKED_CAST")
val storedItems = runSync { storedItemsField.get(swordBlocking) as MutableMap }
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
storedItems.containsKey(player.uniqueId) shouldBe true
}
runSync {
setModeset(player, "new")
swordBlocking.isEnabled(player) shouldBe false
ModuleLoader.getModules().forEach { it.onModesetChange(player) }
}
runSync {
player.inventory.itemInOffHand.type shouldBe Material.APPLE
storedItems.containsKey(player.uniqueId) shouldBe false
val legacyStates = legacyStatesField.get(swordBlocking) as Map<*, *>
legacyStates.containsKey(player.uniqueId) shouldBe false
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/CopperToolsIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XMaterial
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.StringSpec
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import org.bukkit.Material
import org.bukkit.plugin.java.JavaPlugin
@OptIn(ExperimentalKotest::class)
class CopperToolsIntegrationTest : StringSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extension(MainThreadDispatcherExtension(plugin))
val copperMaterials = listOf(
XMaterial.COPPER_SWORD,
XMaterial.COPPER_AXE,
XMaterial.COPPER_PICKAXE,
XMaterial.COPPER_SHOVEL,
XMaterial.COPPER_HOE
).mapNotNull { xmat ->
val material = runCatching { Material.valueOf(xmat.name) }.getOrNull()
material?.let { xmat to it }
}
if (copperMaterials.isEmpty()) {
"copper tools not present on this version" {
plugin.logger.info("Copper tools not present on this version; skipping copper damage checks.")
}
} else {
copperMaterials.forEach { (xmat, material) ->
"copper tool damage is configurable for ${xmat.name}" {
val damage = WeaponDamages.getDamage(material)
if (damage < 0.0) {
throw AssertionError("Expected ${xmat.name} to be present in old-tool-damage.damages (config.yml)")
}
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/CustomWeaponDamageIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.doubles.shouldBeExactly
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.entity.Trident
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
import java.util.concurrent.atomic.AtomicReference
@OptIn(ExperimentalKotest::class)
class CustomWeaponDamageIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val toolDamageModule = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleOldToolDamage not registered")
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
suspend fun TestScope.withWeaponConfig(
tridentMelee: Double?,
tridentThrown: Double?,
mace: Double?,
block: suspend TestScope.() -> Unit
) {
val snapshot = ocm.config.getConfigurationSection("old-tool-damage.damages")?.getValues(false) ?: emptyMap()
fun set(path: String, value: Double?) {
if (value == null) return
ocm.config.set("old-tool-damage.damages.$path", value)
}
try {
set("TRIDENT", tridentMelee)
set("TRIDENT_THROWN", tridentThrown)
set("MACE", mace)
toolDamageModule.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
block()
} finally {
// restore
snapshot.forEach { (k, v) -> ocm.config.set("old-tool-damage.damages.$k", v) }
toolDamageModule.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
}
}
data class SpawnedPlayer(val fake: FakePlayer, val player: Player)
fun spawnFake(location: Location): SpawnedPlayer {
lateinit var fake: FakePlayer
lateinit var player: Player
runSync {
fake = FakePlayer(testPlugin)
fake.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
data.setModesetForWorld(player.world.uid, "old")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)
}
return SpawnedPlayer(fake, player)
}
fun cleanup(vararg players: SpawnedPlayer) {
runSync {
players.forEach { p ->
p.fake.removePlayer()
}
}
}
test("trident melee uses configured base damage") {
val tridentMat = Material.matchMaterial("TRIDENT") ?: return@test
withWeaponConfig(tridentMelee = 12.0, tridentThrown = null, mace = null) {
val world = checkNotNull(Bukkit.getWorld("world"))
val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))
val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))
val damageCapture = AtomicReference()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.damager == attacker.player && event.entity == victim.player) {
damageCapture.set(event.damage)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
attacker.player.inventory.setItemInMainHand(ItemStack(tridentMat))
Bukkit.getPluginManager().callEvent(
EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 8.0)
)
HandlerList.unregisterAll(listener)
}
val dealt = damageCapture.get() ?: error("No damage recorded")
dealt shouldBe (12.0 plusOrMinus 0.05)
cleanup(attacker, victim)
}
}
test("thrown trident uses configured damage") {
val tridentMat = Material.matchMaterial("TRIDENT") ?: return@test
if (!Reflector.versionIsNewerOrEqualTo(1, 13, 0)) return@test
withWeaponConfig(tridentMelee = null, tridentThrown = 15.0, mace = null) {
val world = checkNotNull(Bukkit.getWorld("world"))
val victim = spawnFake(Location(world, 0.0, 100.0, 0.0))
val tridentRef = AtomicReference()
runSync {
tridentRef.set(
world.spawn(world.spawnLocation, Trident::class.java).apply {
this.item = ItemStack(tridentMat)
}
)
}
val damageCapture = AtomicReference()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.damager == tridentRef.get() && event.entity == victim.player) {
damageCapture.set(event.damage)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
Bukkit.getPluginManager().callEvent(
EntityDamageByEntityEvent(tridentRef.get(), victim.player, EntityDamageEvent.DamageCause.PROJECTILE, 8.0)
)
HandlerList.unregisterAll(listener)
}
val dealt = damageCapture.get() ?: error("No damage recorded")
dealt shouldBeExactly 15.0
cleanup(victim)
runSync { tridentRef.get()?.remove() }
}
}
test("mace melee uses configured base damage") {
val maceMat = Material.matchMaterial("MACE") ?: return@test
withWeaponConfig(tridentMelee = null, tridentThrown = null, mace = 10.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))
val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))
val damageCapture = AtomicReference()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.damager == attacker.player && event.entity == victim.player) {
damageCapture.set(event.damage)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
attacker.player.inventory.setItemInMainHand(ItemStack(maceMat))
Bukkit.getPluginManager().callEvent(
EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 6.0)
)
HandlerList.unregisterAll(listener)
}
val dealt = damageCapture.get() ?: error("No damage recorded")
dealt shouldBe (10.0 plusOrMinus 0.05)
cleanup(attacker, victim)
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/DisableOffhandIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class DisableOffhandIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun setModeset(player: Player, modeset: String) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
setPlayerData(player.uniqueId, playerData)
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
}
}
afterSpec {
runSync {
fakePlayer.removePlayer()
}
}
beforeTest {
runSync {
player.inventory.clear()
player.inventory.setItemInOffHand(ItemStack(Material.SHIELD))
setModeset(player, "new")
}
}
test("modeset-change handler ignores players without disable-offhand enabled") {
runSync {
module.isEnabled(player) shouldBe false
val offhand = player.inventory.itemInOffHand.clone()
module.onModesetChange(player)
player.inventory.itemInOffHand.type shouldBe offhand.type
player.inventory.itemInOffHand.amount shouldBe offhand.amount
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/DisableOffhandReflectionIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.event.inventory.ClickType
import org.bukkit.event.inventory.InventoryAction
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.event.inventory.InventoryType
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.InventoryView
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class DisableOffhandReflectionIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
lateinit var fake: FakePlayer
lateinit var player: Player
beforeSpec {
runSync {
val world = Bukkit.getWorld("world") ?: error("world missing")
fake = FakePlayer(testPlugin)
fake.spawn(Location(world, 0.0, 100.0, 0.0))
player = Bukkit.getPlayer(fake.uuid) ?: error("player missing")
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
}
}
afterSpec {
runSync {
fake.removePlayer()
}
}
beforeTest {
runSync {
player.closeInventory()
}
}
test("reflective InventoryClickEvent getView works") {
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val event =
runSync {
InventoryClickEvent(
view,
InventoryType.SlotType.CONTAINER,
0,
ClickType.LEFT,
InventoryAction.PICKUP_ALL,
)
}
val method = Reflector.getMethod(event.javaClass, "getView") ?: error("getView missing")
val reflectedView = Reflector.invokeMethod(method, event)
reflectedView shouldBe view
} finally {
runSync { player.closeInventory() }
}
}
test("reflective InventoryView access returns top and bottom inventories") {
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
try {
val bottomMethod =
Reflector.getMethod(view.javaClass, "getBottomInventory")
?: error("getBottomInventory missing")
val topMethod =
Reflector.getMethod(view.javaClass, "getTopInventory")
?: error("getTopInventory missing")
val bottom =
Reflector.invokeMethod(bottomMethod, view)
?: error("bottom inventory missing")
val top =
Reflector.invokeMethod(topMethod, view)
?: error("top inventory missing")
bottom.type shouldBe InventoryType.PLAYER
} finally {
runSync { player.closeInventory() }
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/EnderpearlCooldownIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.longs.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableEnderpearlCooldown
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.EnderPearl
import org.bukkit.entity.Player
import org.bukkit.event.entity.ProjectileLaunchEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class EnderpearlCooldownIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModuleDisableEnderpearlCooldown not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
}).get()
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
ModuleLoader.toggleModules()
}
suspend fun withConfig(cooldownSeconds: Int, showMessage: Boolean, block: suspend () -> Unit) {
val oldCooldown = ocm.config.getInt("disable-enderpearl-cooldown.cooldown")
val oldShow = ocm.config.getBoolean("disable-enderpearl-cooldown.showMessage")
try {
runSync {
ocm.config.set("disable-enderpearl-cooldown.cooldown", cooldownSeconds)
ocm.config.set("disable-enderpearl-cooldown.showMessage", showMessage)
module.reload()
ModuleLoader.toggleModules()
}
block()
} finally {
runSync {
ocm.config.set("disable-enderpearl-cooldown.cooldown", oldCooldown)
ocm.config.set("disable-enderpearl-cooldown.showMessage", oldShow)
module.reload()
ModuleLoader.toggleModules()
}
}
}
fun firePearlLaunchEvent(player: Player): ProjectileLaunchEvent {
// Spawn a pearl entity directly; the module cancels this and launches a replacement.
val pearl = runSync {
val world = player.world
val entity = world.spawn(player.eyeLocation, EnderPearl::class.java)
entity.shooter = player
entity
}
val event = ProjectileLaunchEvent(pearl)
runSync { Bukkit.getPluginManager().callEvent(event) }
return event
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getWorld("world") ?: error("world not loaded")
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
player.isOp = true
setModeset(player, "old")
}
}
afterSpec {
runSync { fakePlayer.removePlayer() }
}
beforeTest {
runSync {
setModeset(player, "old")
player.gameMode = GameMode.SURVIVAL
player.inventory.clear()
player.inventory.setItemInMainHand(ItemStack(Material.ENDER_PEARL, 16))
}
}
test("cooldown 0 allows repeated throws and consumes an enderpearl in survival") {
withConfig(cooldownSeconds = 0, showMessage = false) {
val before = runSync { player.inventory.itemInMainHand.amount }
val e1 = firePearlLaunchEvent(player)
e1.isCancelled shouldBe true
val after1 = runSync { player.inventory.itemInMainHand.amount }
(before - after1) shouldBe 1
val e2 = firePearlLaunchEvent(player)
e2.isCancelled shouldBe true
val after2 = runSync { player.inventory.itemInMainHand.amount }
(after1 - after2) shouldBe 1
}
}
test("cooldown blocks a second throw within the window and exposes remaining cooldown") {
withConfig(cooldownSeconds = 5, showMessage = false) {
val e1 = firePearlLaunchEvent(player)
e1.isCancelled shouldBe true
val remainingAfterFirst = runSync { module.getEnderpearlCooldown(player.uniqueId) }
remainingAfterFirst shouldBeGreaterThan 0
val before2 = runSync { player.inventory.itemInMainHand.amount }
val e2 = firePearlLaunchEvent(player)
e2.isCancelled shouldBe true
// Second throw attempt should not consume another pearl.
val after2 = runSync { player.inventory.itemInMainHand.amount }
before2 shouldBe after2
}
}
test("creative mode does not consume enderpearls") {
withConfig(cooldownSeconds = 0, showMessage = false) {
runSync { player.gameMode = GameMode.CREATIVE }
val before = runSync { player.inventory.itemInMainHand.amount }
val e1 = firePearlLaunchEvent(player)
e1.isCancelled shouldBe true
val after = runSync { player.inventory.itemInMainHand.amount }
before shouldBe after
}
}
test("no enderpearl item in either hand does not throw or consume") {
withConfig(cooldownSeconds = 0, showMessage = false) {
runSync { player.inventory.clear() }
val e1 = firePearlLaunchEvent(player)
e1.isCancelled shouldBe true
runSync { player.inventory.itemInMainHand.type shouldBe Material.AIR }
}
}
test("cooldown expires after real time and throw becomes allowed again") {
withConfig(cooldownSeconds = 1, showMessage = false) {
val e1 = firePearlLaunchEvent(player)
e1.isCancelled shouldBe true
// Wait slightly over a second to ensure wall-clock cooldown is over.
delay(1200)
val before2 = runSync { player.inventory.itemInMainHand.amount }
val e2 = firePearlLaunchEvent(player)
e2.isCancelled shouldBe true
val after2 = runSync { player.inventory.itemInMainHand.amount }
(before2 - after2) shouldBe 1
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.mojang.authlib.GameProfile
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.ChannelOutboundHandlerAdapter
import io.netty.channel.embedded.EmbeddedChannel
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Entity
import org.bukkit.entity.Player
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerPreLoginEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
import xyz.jpenilla.reflectionremapper.ReflectionRemapper
import java.lang.reflect.Method
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.SocketAddress
import java.net.UnknownHostException
import java.util.*
class FakePlayer(private val plugin: JavaPlugin) {
val uuid: UUID = UUID.randomUUID()
private val name: String = uuid.toString().substring(0, 16)
private lateinit var serverPlayer: Any // NMS ServerPlayer instance
private var bukkitPlayer: Player? = null
private var networkConnection: Any? = null
private var usedPlaceNewPlayer: Boolean = false
private var tickTaskId: Int? = null
private val isLegacy9 = !Reflector.versionIsNewerOrEqualTo(1, 10, 0) // 1.9.x and below
private val isLegacy12 = !Reflector.versionIsNewerOrEqualTo(1, 13, 0) && Reflector.versionIsNewerOrEqualTo(1, 10, 0)
private val legacyImpl9: LegacyFakePlayer9? = if (isLegacy9) LegacyFakePlayer9(plugin, uuid, name) else null
private val legacyImpl12: LegacyFakePlayer12? = if (isLegacy12) LegacyFakePlayer12(plugin, uuid, name) else null
private val reflectionRemapper: ReflectionRemapper = try {
ReflectionRemapper.forReobfMappingsInPaperJar()
} catch (e: Throwable) {
plugin.logger.warning("Reflection mappings not found; using no-op remapper for legacy server.")
ReflectionRemapper.noop()
}
// Helper function to load NMS classes using the appropriate class loader and remap names
fun getNMSClass(name: String): Class<*> {
// Remap the class name
val remappedName = reflectionRemapper.remapClassName(name)
// Get the NMS MinecraftServer from the Bukkit server
val server = Bukkit.getServer()
val craftServerClass = server.javaClass
val getServerMethod = Reflector.getMethod(craftServerClass, "getServer")
?: throw NoSuchMethodException("Cannot find getServer method in ${craftServerClass.name}")
val minecraftServer = Reflector.invokeMethod(getServerMethod, server)
return Class.forName(remappedName, true, minecraftServer.javaClass.classLoader)
}
fun spawn(location: Location) {
if (isLegacy9) {
legacyImpl9!!.spawn(location)
serverPlayer = legacyImpl9.entityPlayer ?: throw IllegalStateException("Legacy9 entity player not created.")
bukkitPlayer = legacyImpl9.bukkitPlayer
return
}
if (isLegacy12) {
legacyImpl12!!.spawn(location)
serverPlayer = legacyImpl12.entityPlayer ?: throw IllegalStateException("Legacy12 entity player not created.")
bukkitPlayer = legacyImpl12.bukkitPlayer
return
}
plugin.logger.info("Spawn: Starting")
// Get the NMS WorldServer (ServerLevel) from the Bukkit world
val world = location.world ?: throw IllegalArgumentException("Location has no world!")
val craftWorldClass = world.javaClass
val getHandleMethod = Reflector.getMethod(craftWorldClass, "getHandle")
?: throw NoSuchMethodException("Cannot find getHandle method in ${craftWorldClass.name}")
val worldServer = Reflector.invokeMethod(getHandleMethod, world)
val minecraftServer = getMinecraftServer()
// Create a GameProfile for the fake player
val gameProfile = GameProfile(uuid, name)
// Get the ServerPlayer class and its constructor
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
val serverPlayerClass = getNMSClass("net.minecraft.server.level.ServerPlayer")
// Create a new instance of ServerPlayer (constructor signature varies by version)
this.serverPlayer = createServerPlayer(
serverPlayerClass,
minecraftServerClass,
minecraftServer,
worldServer,
gameProfile
)
plugin.logger.info("Spawn: created serverPlayer")
// Set up the connection for the ServerPlayer
setupPlayerConnection(minecraftServer, worldServer)
// Set the GameMode to SURVIVAL
setPlayerGameMode("SURVIVAL", minecraftServer)
setPlayerPosition(location)
setPlayerRotation(0f, 0f)
// Fire AsyncPlayerPreLoginEvent
fireAsyncPlayerPreLoginEvent()
// Add the player to the server's player list
usedPlaceNewPlayer = addToPlayerList(minecraftServer)
// Retrieve the Bukkit Player instance
bukkitPlayer = Bukkit.getPlayer(uuid)
?: throw RuntimeException("Bukkit player with UUID $uuid not found!")
// Fire PlayerJoinEvent
firePlayerJoinEvent()
// Notify other players and spawn the fake player
if (!usedPlaceNewPlayer) {
notifyPlayersOfJoin()
spawnPlayerInWorld(worldServer, minecraftServer)
} else if (bukkitPlayer?.world?.players?.contains(bukkitPlayer) != true) {
spawnPlayerInWorld(worldServer, minecraftServer)
}
scheduleServerPlayerTick()
plugin.logger.info("Spawn: completed successfully")
}
private fun setupPlayerConnection(minecraftServer: Any, worldServer: Any) {
// Access ServerGamePacketListenerImpl class
val serverGamePacketListenerImplClass = getNMSClass("net.minecraft.server.network.ServerGamePacketListenerImpl")
// Create a new Connection object
val connectionClass = getNMSClass("net.minecraft.network.Connection")
val packetFlowClass = getNMSClass("net.minecraft.network.protocol.PacketFlow")
val serverboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, "SERVERBOUND")
val packetFlow = runCatching {
Reflector.getEnumConstant(packetFlowClass, serverboundFieldName, "SERVERBOUND")
}.getOrElse {
val clientboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, "CLIENTBOUND")
Reflector.getEnumConstant(packetFlowClass, clientboundFieldName, "CLIENTBOUND")
}
val connectionConstructor = connectionClass.getConstructor(packetFlowClass)
val connection = connectionConstructor.newInstance(packetFlow)
networkConnection = connection
// Create a custom EmbeddedChannel with an overridden remoteAddress()
val remoteAddress = InetSocketAddress("127.0.0.1", 9999)
val embeddedChannel = EmbeddedChannel(ChannelInboundHandlerAdapter())
val pipeline = embeddedChannel.pipeline()
if (pipeline.get("decoder") == null) {
pipeline.addLast("decoder", ChannelInboundHandlerAdapter())
}
if (pipeline.get("encoder") == null) {
pipeline.addLast("encoder", ChannelOutboundHandlerAdapter())
}
// Set the 'channel' field of 'connection' to the custom EmbeddedChannel
val channelFieldName = reflectionRemapper.remapFieldName(connectionClass, "channel")
val channelField = Reflector.getField(connectionClass, channelFieldName)
channelField.isAccessible = true
channelField.set(connection, embeddedChannel)
// Set address field of connection
val addressFieldName = reflectionRemapper.remapFieldName(connectionClass, "address")
val addressField = Reflector.getField(connectionClass, addressFieldName)
addressField.set(connection, remoteAddress)
// Create a new ServerGamePacketListenerImpl instance (constructor signature varies by version)
val serverPlayerClass = serverPlayer.javaClass
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
val listenerInstance = createServerGamePacketListener(
serverGamePacketListenerImplClass,
minecraftServerClass,
connectionClass,
serverPlayerClass,
minecraftServer,
connection,
serverPlayer
)
// Set the listenerInstance to the player's 'connection' field
val connectionFieldName = reflectionRemapper.remapFieldName(serverPlayerClass, "connection")
val connectionField = Reflector.getField(serverPlayerClass, connectionFieldName)
Reflector.setFieldValue(connectionField, serverPlayer, listenerInstance)
val setListenerName = reflectionRemapper.remapMethodName(
connectionClass,
"setListener",
listenerInstance.javaClass
)
val setListenerMethod = Reflector.getMethodAssignable(
connectionClass,
setListenerName,
listenerInstance.javaClass
) ?: Reflector.getMethodAssignable(connectionClass, "setListener", listenerInstance.javaClass)
if (setListenerMethod != null) {
Reflector.invokeMethod(setListenerMethod, connection, listenerInstance)
}
}
private fun createServerGamePacketListener(
listenerClass: Class<*>,
minecraftServerClass: Class<*>,
connectionClass: Class<*>,
serverPlayerClass: Class<*>,
minecraftServer: Any,
connection: Any,
serverPlayer: Any
): Any {
val constructors = listenerClass.constructors.sortedBy { it.parameterCount }
for (ctor in constructors) {
val params = ctor.parameterTypes
if (params.size < 3) continue
if (!params[0].isAssignableFrom(minecraftServerClass)) continue
if (!params[1].isAssignableFrom(connectionClass)) continue
if (!params[2].isAssignableFrom(serverPlayerClass)) continue
val args = ArrayList()
args.add(minecraftServer)
args.add(connection)
args.add(serverPlayer)
var supported = true
for (i in 3 until params.size) {
val param = params[i]
when (param.simpleName) {
"CommonListenerCookie" -> args.add(createCommonListenerCookie(param, serverPlayer))
else -> {
supported = false
break
}
}
}
if (!supported) continue
return ctor.newInstance(*args.toTypedArray())
}
throw NoSuchMethodException("No compatible ServerGamePacketListenerImpl constructor found for ${listenerClass.name}")
}
private fun createCommonListenerCookie(cookieClass: Class<*>, serverPlayer: Any): Any {
val getProfileName = reflectionRemapper.remapMethodName(serverPlayer.javaClass, "getGameProfile")
val getProfileMethod = Reflector.getMethod(serverPlayer.javaClass, getProfileName)
?: Reflector.getMethod(serverPlayer.javaClass, "getGameProfile")
?: throw NoSuchMethodException("getGameProfile not found in ${serverPlayer.javaClass.name}")
val gameProfile = Reflector.invokeMethod(getProfileMethod, serverPlayer)
val remappedName = reflectionRemapper.remapMethodName(
cookieClass,
"createInitial",
GameProfile::class.java,
Boolean::class.javaPrimitiveType
)
val method = Reflector.getMethod(cookieClass, remappedName, "GameProfile", "boolean")
?: Reflector.getMethod(cookieClass, "createInitial", "GameProfile", "boolean")
?: throw NoSuchMethodException("createInitial not found in ${cookieClass.name}")
return Reflector.invokeMethod(method, null, gameProfile, false)
}
private fun createServerPlayer(
serverPlayerClass: Class<*>,
minecraftServerClass: Class<*>,
minecraftServer: Any,
worldServer: Any,
gameProfile: GameProfile
): Any {
val constructors = serverPlayerClass.constructors.sortedBy { it.parameterCount }
for (ctor in constructors) {
val params = ctor.parameterTypes
if (params.size < 3) continue
if (!params[0].isAssignableFrom(minecraftServerClass)) continue
if (!params[1].isAssignableFrom(worldServer.javaClass)) continue
if (params[2] != GameProfile::class.java) continue
val args = ArrayList()
args.add(minecraftServer)
args.add(worldServer)
args.add(gameProfile)
var supported = true
for (i in 3 until params.size) {
val param = params[i]
when (param.simpleName) {
"ProfilePublicKey" -> args.add(null)
"ClientInformation" -> args.add(createDefaultClientInformation(param))
else -> {
supported = false
break
}
}
}
if (!supported) continue
return ctor.newInstance(*args.toTypedArray())
}
throw NoSuchMethodException("No compatible ServerPlayer constructor found for ${serverPlayerClass.name}")
}
private fun createDefaultClientInformation(clientInfoClass: Class<*>): Any {
val remappedName = reflectionRemapper.remapMethodName(clientInfoClass, "createDefault")
val method = Reflector.getMethod(clientInfoClass, remappedName)
?: Reflector.getMethod(clientInfoClass, "createDefault")
?: throw NoSuchMethodException("createDefault not found in ${clientInfoClass.name}")
return Reflector.invokeMethod(method, null)
}
private fun setPlayerGameMode(gameModeName: String, minecraftServer: Any) {
val gameModeClass = getNMSClass("net.minecraft.world.level.GameType")
val gameModeFieldName = reflectionRemapper.remapFieldName(gameModeClass, gameModeName)
val gameModeField = Reflector.getField(gameModeClass, gameModeFieldName)
val gameMode = gameModeField.get(null)
val setGameModeMethodName = reflectionRemapper.remapMethodName(
serverPlayer.javaClass,
"setGameMode",
gameModeClass
)
val setGameModeMethod = serverPlayer.javaClass.getMethod(setGameModeMethodName, gameModeClass)
setGameModeMethod.invoke(serverPlayer, gameMode)
}
private fun setPlayerPosition(location: Location) {
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
val setPosMethodName = reflectionRemapper.remapMethodName(
entityClass,
"setPos",
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType
)
val setPosMethod = checkNotNull(
Reflector.getMethod(
entityClass,
setPosMethodName,
"double",
"double",
"double"
)
)
setPosMethod.invoke(serverPlayer, location.x, location.y, location.z)
}
private fun setPlayerRotation(xRot: Float, yRot: Float) {
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
val xRotFieldName = reflectionRemapper.remapFieldName(entityClass, "xRot")
val xRotField = Reflector.getField(entityClass, xRotFieldName)
xRotField.setFloat(serverPlayer, xRot)
val yRotFieldName = reflectionRemapper.remapFieldName(entityClass, "yRot")
val yRotField = Reflector.getField(entityClass, yRotFieldName)
yRotField.setFloat(serverPlayer, yRot)
}
private fun fireAsyncPlayerPreLoginEvent() {
try {
val ipAddress = InetAddress.getByName("127.0.0.1")
@Suppress("DEPRECATION") // Legacy constructor kept for older server compatibility in tests.
val asyncPreLoginEvent = AsyncPlayerPreLoginEvent(name, ipAddress, uuid)
Thread { Bukkit.getPluginManager().callEvent(asyncPreLoginEvent) }.start()
} catch (e: UnknownHostException) {
plugin.logger.severe("Spawn: UnknownHostException - ${e.message}")
e.printStackTrace()
}
}
private fun addToPlayerList(minecraftServer: Any): Boolean {
val playerList = getPlayerList(minecraftServer)
// Add the player to the PlayerList
val playerListClass = getNMSClass("net.minecraft.server.players.PlayerList")
val placeMethodName = reflectionRemapper.remapMethodName(playerListClass, "placeNewPlayer")
val connection = checkNotNull(networkConnection) { "Connection not initialised" }
val placeMethodWithCookie = Reflector.getMethodAssignable(
playerListClass,
placeMethodName,
connection.javaClass,
serverPlayer.javaClass,
null
) ?: Reflector.getMethodAssignable(
playerListClass,
"placeNewPlayer",
connection.javaClass,
serverPlayer.javaClass,
null
)
if (placeMethodWithCookie != null && placeMethodWithCookie.parameterTypes.size == 3) {
val cookieClass = placeMethodWithCookie.parameterTypes[2]
val cookie = createCommonListenerCookie(cookieClass, serverPlayer)
placeMethodWithCookie.invoke(playerList, connection, serverPlayer, cookie)
return true
}
val placeMethod = Reflector.getMethodAssignable(
playerListClass,
placeMethodName,
connection.javaClass,
serverPlayer.javaClass
) ?: Reflector.getMethodAssignable(
playerListClass,
"placeNewPlayer",
connection.javaClass,
serverPlayer.javaClass
)
if (placeMethod != null) {
placeMethod.invoke(playerList, connection, serverPlayer)
return true
}
val loadMethodName = reflectionRemapper.remapMethodName(playerListClass, "load", serverPlayer.javaClass)
val loadMethod = playerListClass.methods.firstOrNull { method ->
method.name == loadMethodName &&
method.parameterCount == 1 &&
method.parameterTypes[0].isAssignableFrom(serverPlayer.javaClass)
}
if (loadMethod != null) {
Reflector.invokeMethod(loadMethod, playerList, serverPlayer)
val playersFieldName = reflectionRemapper.remapFieldName(playerListClass, "players")
val playersField = playerListClass.getDeclaredField(playersFieldName)
playersField.isAccessible = true
@Suppress("UNCHECKED_CAST") // Reflection into NMS collection; types vary by version.
val players = playersField.get(playerList) as MutableList
players.add(serverPlayer)
// Add player to the UUID map
val playersByUUIDField = Reflector.getMapFieldWithTypes(
playerListClass, UUID::class.java, serverPlayer.javaClass
)
@Suppress("UNCHECKED_CAST") // Reflection into NMS map; types vary by version.
val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as MutableMap
playerByUUID[uuid] = serverPlayer
return false
}
throw NoSuchMethodException("No compatible PlayerList add method found for ${playerListClass.name}")
}
private fun firePlayerJoinEvent() {
val joinMessage = "$name joined the game"
val playerJoinEvent = PlayerJoinEvent(bukkitPlayer!!, joinMessage)
Bukkit.getPluginManager().callEvent(playerJoinEvent)
}
private fun notifyPlayersOfJoin() {
if (Bukkit.getOnlinePlayers().isEmpty()) return
val packet = runCatching { createLegacyPlayerInfoPacket() }.getOrNull()
?: runCatching { createPlayerInfoUpdatePacket() }.getOrNull()
?: return
sendPacket(packet)
}
private fun spawnPlayerInWorld(worldServer: Any, minecraftServer: Any) {
// Add the player to the world
val worldServerClass = worldServer.javaClass
val addNewPlayerMethodName = reflectionRemapper.remapMethodName(
worldServerClass,
"addNewPlayer",
serverPlayer.javaClass
)
val addNewPlayerMethod = Reflector.getMethodAssignable(
worldServerClass,
addNewPlayerMethodName,
serverPlayer.javaClass
)
?: Reflector.getMethodAssignable(worldServerClass, "addNewPlayer", serverPlayer.javaClass)
?: Reflector.getMethodAssignable(worldServerClass, "addPlayer", serverPlayer.javaClass)
?: Reflector.getMethodAssignable(worldServerClass, "addFreshEntity", serverPlayer.javaClass)
?: Reflector.getMethodAssignable(worldServerClass, "addEntity", serverPlayer.javaClass)
if (addNewPlayerMethod != null) {
addNewPlayerMethod.invoke(worldServer, serverPlayer)
} else {
plugin.logger.warning("Spawn: Could not find a world add method for ${worldServerClass.name}")
}
// Send world info to the player
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
runCatching {
val getStatusMethodName = reflectionRemapper.remapMethodName(minecraftServerClass, "getStatus")
val getStatusMethod = Reflector.getMethod(minecraftServerClass, getStatusMethodName)
val status = Reflector.invokeMethod(getStatusMethod!!, minecraftServer)
val sendServerStatusMethodName = reflectionRemapper.remapMethodName(
serverPlayer.javaClass,
"sendServerStatus",
status.javaClass
)
val sendServerStatusMethod = Reflector.getMethod(
serverPlayer.javaClass,
sendServerStatusMethodName,
status.javaClass.simpleName
) ?: Reflector.getMethod(serverPlayer.javaClass, "sendServerStatus", status.javaClass.simpleName)
if (sendServerStatusMethod != null) {
sendServerStatusMethod.invoke(serverPlayer, status)
}
}
// Send ClientboundAddPlayerPacket to all players
runCatching {
val clientboundAddPlayerPacketClass =
getNMSClass("net.minecraft.network.protocol.game.ClientboundAddPlayerPacket")
val playerClassName = getNMSClass("net.minecraft.world.entity.player.Player")
// ServerPlayer is subclass of Player
val clientboundAddPlayerPacketConstructor = clientboundAddPlayerPacketClass.getConstructor(playerClassName)
val packet = clientboundAddPlayerPacketConstructor.newInstance(serverPlayer)
sendPacket(packet)
}
}
private fun createLegacyPlayerInfoPacket(): Any {
val clientboundPlayerInfoPacketClass =
getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket")
val actionClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket\$Action")
val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, "ADD_PLAYER")
val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)
val clientboundPlayerInfoPacketConstructor = clientboundPlayerInfoPacketClass.getConstructor(
actionClass,
Collection::class.java
)
return clientboundPlayerInfoPacketConstructor.newInstance(
addPlayerAction,
listOf(serverPlayer)
)
}
private fun createPlayerInfoUpdatePacket(): Any {
val packetClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket")
val actionClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket\$Action")
val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, "ADD_PLAYER")
val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)
val createInitMethodName = reflectionRemapper.remapMethodName(
packetClass,
"createPlayerInitializing",
serverPlayer.javaClass
)
val createInitMethod = Reflector.getMethodAssignable(
packetClass,
createInitMethodName,
serverPlayer.javaClass
) ?: Reflector.getMethodAssignable(packetClass, "createPlayerInitializing", serverPlayer.javaClass)
if (createInitMethod != null) {
return Reflector.invokeMethod(createInitMethod, null, serverPlayer)
}
val constructor = packetClass.getConstructor(EnumSet::class.java, Collection::class.java)
val enumSetNoneOf = EnumSet::class.java.getMethod("noneOf", Class::class.java)
@Suppress("UNCHECKED_CAST")
val actions = enumSetNoneOf.invoke(null, actionClass) as MutableSet
actions.add(addPlayerAction)
return constructor.newInstance(actions, listOf(serverPlayer))
}
private fun sendPacket(packet: Any) {
// Get the Packet class
val packetClass = getNMSClass("net.minecraft.network.protocol.Packet")
// Send packet to all online players
Bukkit.getOnlinePlayers().forEach { player ->
val craftPlayerClass = player.javaClass
val getHandleMethodName = reflectionRemapper.remapMethodName(craftPlayerClass, "getHandle")
val getHandleMethod = craftPlayerClass.getMethod(getHandleMethodName)
val entityPlayer = getHandleMethod.invoke(player)
val connection = getConnection(entityPlayer)
val sendMethodName = reflectionRemapper.remapMethodName(connection.javaClass, "send", packetClass)
val sendMethod = connection.javaClass.getMethod(sendMethodName, packetClass)
sendMethod.invoke(connection, packet)
}
}
fun getConnection(serverPlayer: Any): Any {
if (isLegacy9) return legacyImpl9!!.getConnection(serverPlayer)
if (isLegacy12) return legacyImpl12!!.getConnection(serverPlayer)
val entityPlayerClass = serverPlayer.javaClass
val connectionFieldName = reflectionRemapper.remapFieldName(entityPlayerClass, "connection")
val connectionField = Reflector.getField(entityPlayerClass, connectionFieldName)
if (connectionField != null) return connectionField.get(serverPlayer)
val legacyField = Reflector.getField(entityPlayerClass, "playerConnection")
return legacyField?.get(serverPlayer) ?: error("No connection field on ${entityPlayerClass.name}")
}
fun removePlayer() {
tickTaskId?.let { Bukkit.getScheduler().cancelTask(it) }
if (isLegacy9) {
legacyImpl9!!.removePlayer()
return
}
if (isLegacy12) {
legacyImpl12!!.removePlayer()
return
}
// Fire PlayerQuitEvent
val quitMessage = "§e$name left the game"
val playerQuitEvent = PlayerQuitEvent(bukkitPlayer!!, quitMessage)
Bukkit.getPluginManager().callEvent(playerQuitEvent)
// Disconnect the player
bukkitPlayer!!.kickPlayer(quitMessage)
// Remove the player from the world
/*
val removeMethodName = reflectionRemapper.remapMethodName(
serverPlayer.javaClass,
"remove"
)
val removeMethod = serverPlayer.javaClass.getMethod(removeMethodName)
removeMethod.invoke(serverPlayer)
*/
// Remove from playerList if still present and not already removed
runCatching {
val playerList = getPlayerList(getMinecraftServer())
if (!isEntityRemoved(serverPlayer) && isPlayerListed(playerList)) {
val removePlayerMethodName = reflectionRemapper.remapMethodName(
playerList.javaClass,
"remove",
serverPlayer.javaClass
)
val removePlayerMethod = playerList.javaClass.getMethod(removePlayerMethodName, serverPlayer.javaClass)
removePlayerMethod.invoke(playerList, serverPlayer)
}
}
// Close the connection properly
val connection = getConnection(serverPlayer)
val disconnectMethodName = reflectionRemapper.remapMethodName(
connection.javaClass,
"disconnect",
getNMSClass("net.minecraft.network.chat.Component")
)
val disconnectMethod =
connection.javaClass.getMethod(disconnectMethodName, getNMSClass("net.minecraft.network.chat.Component"))
val quitMessageNoColour = "$name left the game"
val disconnectMessage = getNMSComponent(quitMessageNoColour)
disconnectMethod.invoke(connection, disconnectMessage)
}
private fun scheduleServerPlayerTick() {
if (isLegacy9 || isLegacy12) return
val serverPlayerClass = serverPlayer.javaClass
val tickMethod = resolveServerPlayerTickMethod(serverPlayerClass)
val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)
val getRemainingFireTicksMethod = runCatching {
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
val remapped = reflectionRemapper.remapMethodName(entityClass, "getRemainingFireTicks")
Reflector.getMethod(entityClass, remapped) ?: Reflector.getMethod(entityClass, "getRemainingFireTicks")
}.getOrNull()
tickTaskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
val remainingFireTicks = if (getRemainingFireTicksMethod != null) {
runCatching { getRemainingFireTicksMethod.invoke(serverPlayer) as Int }.getOrNull()
} else {
null
}
if (remainingFireTicks != null && remainingFireTicks > 0) {
val player = bukkitPlayer
val inWater = player != null && (
player.location.block.type == Material.WATER ||
player.eyeLocation.block.type == Material.WATER
)
if (inWater && tickMethod != null) {
runCatching { tickMethod.invoke(serverPlayer) }
return@Runnable
}
if (baseTickMethod != null) {
runCatching { baseTickMethod.invoke(serverPlayer) }
return@Runnable
}
}
if (tickMethod != null) {
runCatching { tickMethod.invoke(serverPlayer) }
} else if (baseTickMethod != null) {
runCatching { baseTickMethod.invoke(serverPlayer) }
}
}, 1L, 1L)
}
private fun resolveServerPlayerTickMethod(serverPlayerClass: Class<*>): Method? {
val candidateNames = listOf("doTick", "tick")
for (name in candidateNames) {
val remapped = reflectionRemapper.remapMethodName(serverPlayerClass, name)
val method = Reflector.getMethod(serverPlayerClass, remapped) ?: Reflector.getMethod(serverPlayerClass, name)
if (method != null && method.parameterCount == 0) {
method.isAccessible = true
return method
}
}
val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)
if (baseTickMethod != null) {
return baseTickMethod
}
val fallback = serverPlayerClass.methods.firstOrNull { method ->
method.parameterCount == 0 &&
method.returnType == Void.TYPE &&
method.name.lowercase().contains("tick")
} ?: serverPlayerClass.methods.firstOrNull { method ->
method.parameterCount == 0 && method.returnType == Void.TYPE
}
fallback?.isAccessible = true
return fallback
}
private fun resolveBaseTickMethod(serverPlayerClass: Class<*>): Method? {
val entityClass = runCatching { getNMSClass("net.minecraft.world.entity.Entity") }.getOrNull()
val candidateNames = buildList {
entityClass?.let { add(reflectionRemapper.remapMethodName(it, "baseTick")) }
add("baseTick")
}.distinct()
for (name in candidateNames) {
val method = (serverPlayerClass.declaredMethods + serverPlayerClass.methods).firstOrNull { candidate ->
candidate.name == name && candidate.parameterCount == 0
} ?: entityClass?.let { clazz ->
(clazz.declaredMethods + clazz.methods).firstOrNull { candidate ->
candidate.name == name && candidate.parameterCount == 0
}
}
if (method != null) {
method.isAccessible = true
return method
}
}
return null
}
private fun getNMSComponent(message: String): Any {
// Convert a String to an NMS Component
val componentClass = getNMSClass("net.minecraft.network.chat.Component")
val literalName = reflectionRemapper.remapMethodName(componentClass, "literal", String::class.java)
val componentMethod = checkNotNull(Reflector.getMethod(componentClass, literalName, "String"))
return componentMethod.invoke(null, message)
}
private fun getMinecraftServer(): Any {
val server = Bukkit.getServer()
val craftServerClass = server.javaClass
val getServerMethod = Reflector.getMethod(craftServerClass, "getServer")
?: throw NoSuchMethodException("Cannot find getServer method in ${craftServerClass.name}")
return Reflector.invokeMethod(getServerMethod, server)
}
private fun getPlayerList(minecraftServer: Any): Any {
val playerListFieldName = reflectionRemapper.remapMethodName(minecraftServer.javaClass, "getPlayerList")
val playerListMethod = checkNotNull(Reflector.getMethod(minecraftServer.javaClass, playerListFieldName))
return Reflector.invokeMethod(playerListMethod, minecraftServer)
}
private fun isPlayerListed(playerList: Any): Boolean {
val playerListClass = getNMSClass("net.minecraft.server.players.PlayerList")
return runCatching {
val playersByUUIDField = Reflector.getMapFieldWithTypes(
playerListClass,
UUID::class.java,
serverPlayer.javaClass
)
@Suppress("UNCHECKED_CAST") // Reflection into NMS map; types vary by version.
val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as Map
playerByUUID.containsKey(uuid)
}.getOrElse {
val getPlayerMethodName = reflectionRemapper.remapMethodName(playerListClass, "getPlayer", UUID::class.java)
val getPlayerMethod = Reflector.getMethodAssignable(
playerListClass,
getPlayerMethodName,
UUID::class.java
) ?: Reflector.getMethodAssignable(playerListClass, "getPlayer", UUID::class.java)
if (getPlayerMethod != null) {
Reflector.invokeMethod(getPlayerMethod, playerList, uuid) != null
} else {
true
}
}
}
private fun isEntityRemoved(entity: Any): Boolean {
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
val isRemovedMethodName = reflectionRemapper.remapMethodName(entityClass, "isRemoved")
val isRemovedMethod = Reflector.getMethod(entityClass, isRemovedMethodName)
?: Reflector.getMethod(entityClass, "isRemoved")
if (isRemovedMethod != null) {
return Reflector.invokeMethod(isRemovedMethod, entity)
}
return runCatching {
val removedFieldName = reflectionRemapper.remapFieldName(entityClass, "removed")
val removedField = Reflector.getField(entityClass, removedFieldName)
removedField.getBoolean(entity)
}.getOrDefault(false)
}
fun attack(bukkitEntity: Entity) {
attackCompat(checkNotNull(bukkitPlayer), bukkitEntity)
}
fun doBlocking() {
if (isLegacy9 || isLegacy12) {
// Legacy blocking: ensure sword in hand + shield in offhand, then raise offhand.
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.DIAMOND_SWORD))
if (bukkitPlayer!!.inventory.itemInOffHand.type != Material.SHIELD) {
bukkitPlayer!!.inventory.setItemInOffHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
}
bukkitPlayer!!.updateInventory()
if (isLegacy12) legacyImpl12?.startUsingOffhand()
return
}
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
val livingEntityClass = getNMSClass("net.minecraft.world.entity.LivingEntity")
// Start using item (simulate blocking)
val interactionHandClass = getNMSClass("net.minecraft.world.InteractionHand")
val mainHandFieldName = reflectionRemapper.remapFieldName(interactionHandClass, "MAIN_HAND")
val mainHandField = interactionHandClass.getDeclaredField(mainHandFieldName)
val mainHand = mainHandField.get(null)
val startUsingItemMethodName = reflectionRemapper.remapMethodName(
livingEntityClass,
"startUsingItem",
interactionHandClass
)
val startUsingItemMethod = checkNotNull(
Reflector.getMethod(livingEntityClass, startUsingItemMethodName, interactionHandClass.simpleName)
)
Reflector.invokeMethod(startUsingItemMethod, serverPlayer, mainHand)
// Manually set useItemRemaining field to simulate blocking
val useItemRemainingFieldName = reflectionRemapper.remapFieldName(livingEntityClass, "useItemRemaining")
val useItemRemainingField = livingEntityClass.getDeclaredField(useItemRemainingFieldName)
useItemRemainingField.isAccessible = true
useItemRemainingField.setInt(serverPlayer, 200)
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FireAspectOverdamageIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XEnchantment
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeLessThan
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.shouldBe
import io.kotest.assertions.withClue
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.AttributeModifier
import org.bukkit.entity.Entity
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.util.Vector
import kotlinx.coroutines.delay
import java.util.concurrent.Callable
import kotlin.math.abs
@OptIn(ExperimentalKotest::class)
class FireAspectOverdamageIntegrationTest : FunSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extensions(MainThreadDispatcherExtension(plugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(plugin, Callable {
action()
null
}).get()
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
val isLegacyServer = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.versionIsNewerOrEqualTo(1, 13, 0)
fun needsAttackWarmup(attacker: Player): Boolean = isLegacyServer
data class AttackSample(
val cancelled: Boolean,
val damage: Double,
val finalDamage: Double,
val noDamageTicks: Int,
val lastDamage: Double
)
data class FireTickSample(val cancelled: Boolean, val finalDamage: Double)
suspend fun waitForFireTick(samples: List, timeoutTicks: Long = 120) {
repeat(timeoutTicks.toInt()) {
if (samples.isNotEmpty()) return
delayTicks(1)
}
error("Expected a fire tick event within $timeoutTicks ticks, but none fired.")
}
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun equip(player: Player, item: ItemStack) {
prepareWeapon(item)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()
if (attackSpeedAttribute != null) {
player.getAttribute(attackSpeedAttribute)?.baseValue = 1000.0
}
if (isLegacyServer) {
// Legacy fake players can miss item-based attack damage modifiers until after the first swing.
// Force a stable baseline so overdamage tests are not dependent on attribute refresh timing.
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attribute = attackDamageAttribute?.let { player.getAttribute(it) }
if (attribute != null) {
attribute.baseValue = when (item.type) {
Material.DIAMOND_SWORD -> 7.0
else -> attribute.baseValue
}
}
}
player.inventory.setItemInMainHand(item)
player.updateInventory()
}
fun spawnPlayer(location: Location): Pair {
val fake = FakePlayer(plugin)
fake.spawn(location)
val player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
return fake to player
}
fun spawnVictim(location: Location): LivingEntity {
val world = location.world ?: error("World missing for victim spawn")
return world.spawn(location, org.bukkit.entity.Zombie::class.java).apply {
maximumNoDamageTicks = 100
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
fun prepareVictimState(victim: LivingEntity, maxHealthOverride: Double? = null) {
if (maxHealthOverride != null) {
val maxHealthAttribute = XAttribute.MAX_HEALTH.get()
if (maxHealthAttribute != null) {
victim.getAttribute(maxHealthAttribute)?.baseValue = maxHealthOverride
} else {
victim.maxHealth = maxHealthOverride
}
}
victim.maximumNoDamageTicks = 100
victim.noDamageTicks = 0
victim.lastDamage = 0.0
victim.fireTicks = 0
victim.isInvulnerable = false
victim.health = victim.maxHealth
}
fun ensureBurning(victim: LivingEntity, minTicks: Int = 200) {
if (victim.fireTicks < minTicks) {
victim.fireTicks = minTicks
}
}
fun requireInvulnerabilityWindow(victim: LivingEntity) {
val maxTicks = victim.maximumNoDamageTicks
if (victim.noDamageTicks.toDouble() <= maxTicks / 2.0) {
error(
"Expected to still be inside the invulnerability window, but noDamageTicks=" +
"${victim.noDamageTicks} maxNoDamageTicks=$maxTicks"
)
}
}
fun applyProtectionArmour(entity: LivingEntity) {
val protection = XEnchantment.PROTECTION.get()
val armour = arrayOf(
ItemStack(Material.DIAMOND_BOOTS),
ItemStack(Material.DIAMOND_LEGGINGS),
ItemStack(Material.DIAMOND_CHESTPLATE),
ItemStack(Material.DIAMOND_HELMET)
)
if (protection != null) {
armour.forEach { it.addUnsafeEnchantment(protection, 4) }
}
if (entity is Player) {
entity.inventory.setArmorContents(armour)
return
}
entity.equipment?.armorContents = armour
}
fun applyFireProtectionArmour(entity: LivingEntity) {
val fireProtection = XEnchantment.FIRE_PROTECTION.get()
val armour = arrayOf(
ItemStack(Material.DIAMOND_BOOTS),
ItemStack(Material.DIAMOND_LEGGINGS),
ItemStack(Material.DIAMOND_CHESTPLATE),
ItemStack(Material.DIAMOND_HELMET)
)
if (fireProtection != null) {
armour.forEach { it.addUnsafeEnchantment(fireProtection, 4) }
}
if (entity is Player) {
entity.inventory.setArmorContents(armour)
return
}
entity.equipment?.armorContents = armour
}
fun applyFireResistance(entity: LivingEntity, durationTicks: Int = 20 * 60) {
entity.addPotionEffect(PotionEffect(PotionEffectType.FIRE_RESISTANCE, durationTicks, 0), true)
}
fun disableAiIfPossible(entity: LivingEntity) {
runCatching {
val method = entity.javaClass.methods.firstOrNull { m ->
m.name == "setAI" &&
m.parameterTypes.size == 1 &&
(m.parameterTypes[0] == java.lang.Boolean.TYPE || m.parameterTypes[0] == java.lang.Boolean::class.java)
} ?: return
method.invoke(entity, false)
}
}
fun stabilise(entity: Entity, location: Location) {
entity.fallDistance = 0.0f
entity.teleport(location)
entity.velocity = Vector(0, 0, 0)
}
data class BlockTypeSnapshot(val location: Location, val type: Material)
fun placeWaterColumn(world: org.bukkit.World, base: Location, height: Int = 2): List {
val x = base.blockX
val y = base.blockY
val z = base.blockZ
val snapshots = mutableListOf()
repeat(height) { dy ->
val block = world.getBlockAt(x, y + dy, z)
snapshots.add(BlockTypeSnapshot(block.location, block.type))
block.type = Material.WATER
}
return snapshots
}
fun restoreBlocks(snapshots: List) {
snapshots.forEach { snap ->
val world = snap.location.world ?: return@forEach
world.getBlockAt(snap.location.blockX, snap.location.blockY, snap.location.blockZ).type = snap.type
}
}
suspend fun countSuccessfulAttacks(
attacker: Player,
victim: LivingEntity,
weapon: ItemStack,
attempts: Int,
tickDelay: Long
): Int {
var count = 0
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
if (event.damager.uniqueId != attacker.uniqueId) return
if (event.cause != EntityDamageEvent.DamageCause.ENTITY_ATTACK) return
if (!event.isCancelled) {
count++
}
}
}
Bukkit.getPluginManager().registerEvents(listener, plugin)
try {
equip(attacker, weapon)
repeat(attempts) {
runSync {
attackCompat(attacker, victim)
}
delayTicks(tickDelay)
}
} finally {
HandlerList.unregisterAll(listener)
}
return count
}
suspend fun collectFireTickDamages(
victim: LivingEntity,
expectedCount: Int,
trigger: suspend () -> Unit
): List {
val samples = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onFireTick(event: EntityDamageEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
val cause = event.cause
if (cause != EntityDamageEvent.DamageCause.FIRE_TICK &&
cause != EntityDamageEvent.DamageCause.FIRE
) return
samples.add(FireTickSample(event.isCancelled, event.finalDamage))
}
}
Bukkit.getPluginManager().registerEvents(listener, plugin)
try {
trigger()
repeat(200) {
if (samples.size >= expectedCount) return@repeat
delayTicks(1)
}
} finally {
HandlerList.unregisterAll(listener)
}
if (samples.size < expectedCount) {
error("Expected $expectedCount fire tick events, got ${samples.size}")
}
val nonCancelled = samples.filterNot { it.cancelled }
if (nonCancelled.size < expectedCount) {
error(
"Expected $expectedCount non-cancelled fire tick events, got ${nonCancelled.size} " +
"(cancelled=${samples.count { it.cancelled }})"
)
}
return nonCancelled.take(expectedCount).map { it.finalDamage }
}
fun snapshotOverdamageState(
attacker: Player,
victim: LivingEntity,
attackSamples: List,
fireTickSamples: List
): String {
var snapshot = ""
runSync {
val meta = victim.getMetadata("ocm-last-damage")
.joinToString(prefix = "[", postfix = "]") { m ->
"${m.owningPlugin?.name ?: "?"}=${runCatching { m.asDouble() }.getOrNull()}"
}
val attackDetails = attackSamples.joinToString(prefix = "[", postfix = "]") {
"{c=${it.cancelled}, dmg=${it.damage}, final=${it.finalDamage}, ndt=${it.noDamageTicks}, ld=${it.lastDamage}}"
}
snapshot =
"attacks(total=${attackSamples.size}, ok=${attackSamples.count { !it.cancelled }}, " +
"cancelled=${attackSamples.count { it.cancelled }}, details=$attackDetails) " +
"fireTicks(total=${fireTickSamples.size}, ok=${fireTickSamples.count { !it.cancelled }}, " +
"cancelled=${fireTickSamples.count { it.cancelled }}) " +
"attacker(onGround=${attacker.isOnGround}, fallDistance=${attacker.fallDistance}, " +
"sprinting=${attacker.isSprinting}, velocity=${attacker.velocity}) " +
"victim(noDamageTicks=${victim.noDamageTicks}, maxNoDamageTicks=${victim.maximumNoDamageTicks}, " +
"lastDamage=${victim.lastDamage}, health=${victim.health}/${victim.maxHealth}, " +
"fireTicks=${victim.fireTicks}, meta=$meta)"
}
return snapshot
}
test("fire aspect does not bypass invulnerability cancellation") {
if (!Reflector.versionIsNewerOrEqualTo(1, 12, 0)) return@test
val attackSamples = mutableListOf()
val fireTickSamples = mutableListOf()
lateinit var attacker: Player
var victim: LivingEntity? = null
var fakeAttacker: FakePlayer? = null
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
attackSamples.add(
AttackSample(
cancelled = event.isCancelled,
damage = event.damage,
finalDamage = event.finalDamage,
noDamageTicks = currentVictim.noDamageTicks,
lastDamage = currentVictim.lastDamage
)
)
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onFireTick(event: EntityDamageEvent) {
val cause = event.cause
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
(cause == EntityDamageEvent.DamageCause.FIRE_TICK || cause == EntityDamageEvent.DamageCause.FIRE)
) {
fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(checkNotNull(victim))
Bukkit.getPluginManager().registerEvents(listener, plugin)
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
}
// Vanilla 1.12 scales attack damage by cooldown *before* the Bukkit damage event is fired.
// Fake players can start with an incomplete cooldown, so wait a few ticks to make the first hit stable.
if (needsAttackWarmup(attacker)) {
delayTicks(6)
}
runSync {
attackCompat(attacker, checkNotNull(victim))
}
delayTicks(2)
runSync {
val fireEvent = EntityDamageEvent(
checkNotNull(victim),
EntityDamageEvent.DamageCause.FIRE_TICK,
1.0
)
Bukkit.getPluginManager().callEvent(fireEvent)
}
waitForFireTick(fireTickSamples, timeoutTicks = 5)
runSync {
requireInvulnerabilityWindow(checkNotNull(victim))
}
runSync {
attackCompat(attacker, checkNotNull(victim))
}
delayTicks(2)
val state = snapshotOverdamageState(attacker, checkNotNull(victim), attackSamples, fireTickSamples)
withClue(state) {
attackSamples.count { !it.cancelled }.shouldBeExactly(1)
}
} finally {
HandlerList.unregisterAll(listener)
runSync {
fakeAttacker?.removePlayer()
victim?.remove()
}
}
}
test("fire tick does not clear overdamage baseline") {
val attackSamples = mutableListOf()
val fireTickSamples = mutableListOf()
lateinit var attacker: Player
var victim: LivingEntity? = null
var fakeAttacker: FakePlayer? = null
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
attackSamples.add(
AttackSample(
cancelled = event.isCancelled,
damage = event.damage,
finalDamage = event.finalDamage,
noDamageTicks = currentVictim.noDamageTicks,
lastDamage = currentVictim.lastDamage
)
)
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onFireTick(event: EntityDamageEvent) {
val cause = event.cause
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
(cause == EntityDamageEvent.DamageCause.FIRE_TICK || cause == EntityDamageEvent.DamageCause.FIRE)
) {
fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(checkNotNull(victim))
Bukkit.getPluginManager().registerEvents(listener, plugin)
val weapon = ItemStack(Material.DIAMOND_SWORD)
equip(attacker, weapon)
}
if (needsAttackWarmup(attacker)) {
delayTicks(6)
}
runSync {
attackCompat(attacker, checkNotNull(victim))
}
delayTicks(2)
runSync {
val fireEvent = EntityDamageEvent(
checkNotNull(victim),
EntityDamageEvent.DamageCause.FIRE_TICK,
1.0
)
Bukkit.getPluginManager().callEvent(fireEvent)
}
waitForFireTick(fireTickSamples, timeoutTicks = 5)
runSync {
requireInvulnerabilityWindow(checkNotNull(victim))
}
runSync {
attackCompat(attacker, checkNotNull(victim))
}
delayTicks(2)
val state = snapshotOverdamageState(attacker, checkNotNull(victim), attackSamples, fireTickSamples)
withClue(state) {
attackSamples.count { !it.cancelled }.shouldBeExactly(1)
}
} finally {
HandlerList.unregisterAll(listener)
runSync {
fakeAttacker?.removePlayer()
victim?.remove()
}
}
}
test("fire aspect afterburn matches environmental fire tick damage (zombie)") {
lateinit var attacker: Player
lateinit var victim: LivingEntity
var fakeAttacker: FakePlayer? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(victim)
}
val environmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
val afterburn = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
attackCompat(attacker, victim)
ensureBurning(victim, minTicks = 200)
}
delayTicks(12)
}
val environmentalAvg = environmental.average()
val afterburnAvg = afterburn.average()
abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)
} finally {
runSync {
fakeAttacker?.removePlayer()
victim.remove()
}
}
}
test("fire aspect afterburn matches environmental fire tick damage (player)") {
if (isLegacyServer) return@test
lateinit var attacker: Player
lateinit var victim: Player
var fakeAttacker: FakePlayer? = null
var fakeVictim: FakePlayer? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
val (fakeV, playerV) = spawnPlayer(victimLocation)
fakeVictim = fakeV
victim = playerV
prepareVictimState(victim)
}
val environmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
val afterburn = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
attackCompat(attacker, victim)
ensureBurning(victim, minTicks = 200)
}
delayTicks(12)
}
val environmentalAvg = environmental.average()
val afterburnAvg = afterburn.average()
abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)
} finally {
runSync {
fakeAttacker?.removePlayer()
fakeVictim?.removePlayer()
}
}
}
test("fire aspect afterburn matches environmental fire tick damage with protection armour (zombie)") {
// Use an armoured mob victim to keep fire tick sampling stable across versions.
lateinit var attacker: Player
lateinit var victim: LivingEntity
var fakeAttacker: FakePlayer? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(victim)
applyProtectionArmour(victim)
}
val environmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
val afterburn = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
attackCompat(attacker, victim)
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
delayTicks(12)
}
val environmentalAvg = environmental.average()
val afterburnAvg = afterburn.average()
abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)
} finally {
runSync {
fakeAttacker?.removePlayer()
victim.remove()
}
}
}
test("fire aspect afterburn matches environmental fire tick damage with protection armour (player)") {
if (isLegacyServer) return@test
lateinit var attacker: Player
lateinit var victim: Player
var fakeAttacker: FakePlayer? = null
var fakeVictim: FakePlayer? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
val (fakeV, playerV) = spawnPlayer(victimLocation)
fakeVictim = fakeV
victim = playerV
prepareVictimState(victim)
applyProtectionArmour(victim)
}
val environmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
val afterburn = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
attackCompat(attacker, victim)
ensureBurning(victim, minTicks = 200)
}
delayTicks(12)
}
val environmentalAvg = environmental.average()
val afterburnAvg = afterburn.average()
abs(afterburnAvg - environmentalAvg).shouldBeLessThan(0.25)
} finally {
runSync {
fakeAttacker?.removePlayer()
fakeVictim?.removePlayer()
}
}
}
test("fire aspect does not increase successful hits during rapid clicking") {
lateinit var attacker: Player
lateinit var victim: LivingEntity
var fakeAttacker: FakePlayer? = null
var fireTickCount = 0
val fireTickSamples = mutableListOf()
val fireTickListener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onFireTick(event: EntityDamageEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
val cause = event.cause
if (cause == EntityDamageEvent.DamageCause.FIRE ||
cause == EntityDamageEvent.DamageCause.FIRE_TICK
) {
fireTickCount++
fireTickSamples.add(FireTickSample(event.isCancelled, event.finalDamage))
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(victim, maxHealthOverride = 200.0)
}
val baselineWeapon = ItemStack(Material.DIAMOND_SWORD)
val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
item.addUnsafeEnchantment(fireAspect, 2)
}
}
Bukkit.getPluginManager().registerEvents(fireTickListener, plugin)
runSync {
prepareVictimState(victim, maxHealthOverride = 200.0)
}
val baselineHits = countSuccessfulAttacks(attacker, victim, baselineWeapon, attempts = 30, tickDelay = 1)
runSync {
prepareVictimState(victim, maxHealthOverride = 200.0)
}
val fireHits = countSuccessfulAttacks(attacker, victim, fireWeapon, attempts = 5, tickDelay = 1).also {
runSync { ensureBurning(victim, minTicks = 200) }
}
waitForFireTick(fireTickSamples)
val remainingHits = countSuccessfulAttacks(attacker, victim, fireWeapon, attempts = 25, tickDelay = 1)
val totalFireHits = fireHits + remainingHits
fireTickCount.shouldBeGreaterThan(0)
abs(totalFireHits - baselineHits).shouldBeLessThanOrEqual(2)
} finally {
HandlerList.unregisterAll(fireTickListener)
runSync {
fakeAttacker?.removePlayer()
victim.remove()
}
}
}
test("fire aspect does not increase successful hits for fire resistant or fire immune victims") {
lateinit var attacker: Player
var fakeAttacker: FakePlayer? = null
var fakeVictim: FakePlayer? = null
var blaze: LivingEntity? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
val (fakeV, playerV) = spawnPlayer(victimLocation)
fakeVictim = fakeV
prepareVictimState(playerV, maxHealthOverride = 200.0)
applyFireResistance(playerV)
}
val baselineWeapon = ItemStack(Material.DIAMOND_SWORD)
val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) item.addUnsafeEnchantment(fireAspect, 2)
}
val fireResBaseline = countSuccessfulAttacks(
attacker = attacker,
victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid)),
weapon = baselineWeapon,
attempts = 30,
tickDelay = 1
)
runSync {
val victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid))
prepareVictimState(victim, maxHealthOverride = 200.0)
applyFireResistance(victim)
}
val fireResWithFireAspect = countSuccessfulAttacks(
attacker = attacker,
victim = checkNotNull(Bukkit.getPlayer(checkNotNull(fakeVictim).uuid)),
weapon = fireWeapon,
attempts = 30,
tickDelay = 1
)
abs(fireResWithFireAspect - fireResBaseline).shouldBeLessThanOrEqual(2)
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val blazeLocation = Location(world, 1.2, 100.0, 2.0)
blaze = world.spawn(blazeLocation, org.bukkit.entity.Blaze::class.java).apply {
maximumNoDamageTicks = 100
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
disableAiIfPossible(checkNotNull(blaze))
stabilise(checkNotNull(blaze), blazeLocation)
}
val blazeBaseline = countSuccessfulAttacks(
attacker = attacker,
victim = checkNotNull(blaze),
weapon = baselineWeapon,
attempts = 30,
tickDelay = 1
)
runSync {
prepareVictimState(checkNotNull(blaze), maxHealthOverride = 200.0)
stabilise(checkNotNull(blaze), checkNotNull(blaze).location)
}
val blazeWithFireAspect = countSuccessfulAttacks(
attacker = attacker,
victim = checkNotNull(blaze),
weapon = fireWeapon,
attempts = 30,
tickDelay = 1
)
abs(blazeWithFireAspect - blazeBaseline).shouldBeLessThanOrEqual(2)
} finally {
runSync {
fakeAttacker?.removePlayer()
fakeVictim?.removePlayer()
blaze?.remove()
}
}
}
test("fire protection reduces fire tick damage and afterburn matches environmental") {
lateinit var attacker: Player
lateinit var victim: LivingEntity
var fakeAttacker: FakePlayer? = null
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnPlayer(attackerLocation)
fakeAttacker = fakeA
attacker = playerA
victim = spawnVictim(victimLocation)
prepareVictimState(victim)
}
runSync { applyProtectionArmour(victim) }
val protEnvironmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
runSync {
prepareVictimState(victim)
applyFireProtectionArmour(victim)
}
val fireProtEnvironmental = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
ensureBurning(victim, minTicks = 200)
}
}
val tolerance = if (Reflector.versionIsNewerOrEqualTo(1, 20, 0)) 0.35 else 0.5
fireProtEnvironmental.average().shouldBeLessThan(protEnvironmental.average() + tolerance + 1e-6)
runSync {
prepareVictimState(victim)
applyFireProtectionArmour(victim)
}
val afterburn = collectFireTickDamages(victim, 1) {
runSync {
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
victim.lastDamage = 0.0
val weapon = ItemStack(Material.DIAMOND_SWORD)
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) {
weapon.addUnsafeEnchantment(fireAspect, 2)
}
equip(attacker, weapon)
attackCompat(attacker, victim)
ensureBurning(victim, minTicks = 200)
}
delayTicks(12)
}
abs(afterburn.average() - fireProtEnvironmental.average()).shouldBeLessThan(tolerance)
} finally {
runSync {
fakeAttacker?.removePlayer()
victim.remove()
}
}
}
test("water extinguishes fire without fire tick damage") {
lateinit var victim: Player
var fakeVictim: FakePlayer? = null
var blockSnapshots: List = emptyList()
val samples = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onFireTick(event: EntityDamageEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
val cause = event.cause
if (cause != EntityDamageEvent.DamageCause.FIRE_TICK &&
cause != EntityDamageEvent.DamageCause.FIRE
) return
samples.add(FireTickSample(event.isCancelled, event.finalDamage))
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val victimLocation = Location(world, 0.5, 100.0, 0.5)
val (fakeV, playerV) = spawnPlayer(victimLocation)
fakeVictim = fakeV
victim = playerV
prepareVictimState(victim)
blockSnapshots = placeWaterColumn(world, victimLocation, height = 2)
stabilise(victim, victimLocation)
Bukkit.getPluginManager().registerEvents(listener, plugin)
ensureBurning(victim, minTicks = 200)
}
// Allow the initial tick or two to settle (some versions may still emit an early fire damage event).
delayTicks(2)
runSync { samples.clear() }
delayTicks(20)
val hadPositiveDamage = samples.any { !it.cancelled && it.finalDamage > 0.0 }
hadPositiveDamage.shouldBe(false)
runSync {
victim.fireTicks.shouldBeLessThanOrEqual(1)
}
} finally {
HandlerList.unregisterAll(listener)
runSync {
restoreBlocks(blockSnapshots)
fakeVictim?.removePlayer()
}
}
}
test("fire tick does not let a second attacker bypass invulnerability") {
lateinit var attackerA: Player
lateinit var attackerB: Player
lateinit var victim: LivingEntity
var fakeA: FakePlayer? = null
var fakeB: FakePlayer? = null
val samples = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
if (event.cause != EntityDamageEvent.DamageCause.ENTITY_ATTACK) return
if (event.damager.uniqueId != attackerA.uniqueId &&
event.damager.uniqueId != attackerB.uniqueId
) return
samples.add(
AttackSample(
cancelled = event.isCancelled,
damage = event.damage,
finalDamage = event.finalDamage,
noDamageTicks = victim.noDamageTicks,
lastDamage = victim.lastDamage
)
)
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val aLocation = Location(world, 0.0, 100.0, 0.0)
val bLocation = Location(world, 0.0, 100.0, 2.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fa, pa) = spawnPlayer(aLocation)
fakeA = fa
attackerA = pa
val (fb, pb) = spawnPlayer(bLocation)
fakeB = fb
attackerB = pb
victim = spawnVictim(victimLocation)
prepareVictimState(victim)
val fireWeapon = ItemStack(Material.DIAMOND_SWORD).also { item ->
val fireAspect = XEnchantment.FIRE_ASPECT.get()
if (fireAspect != null) item.addUnsafeEnchantment(fireAspect, 2)
}
equip(attackerA, fireWeapon)
equip(attackerB, ItemStack(Material.DIAMOND_SWORD))
Bukkit.getPluginManager().registerEvents(listener, plugin)
}
if (needsAttackWarmup(attackerA)) {
delayTicks(6)
}
runSync { attackCompat(attackerA, victim) }
delayTicks(2)
runSync {
val fireEvent = EntityDamageEvent(victim, EntityDamageEvent.DamageCause.FIRE_TICK, 1.0)
Bukkit.getPluginManager().callEvent(fireEvent)
}
runSync { requireInvulnerabilityWindow(victim) }
if (needsAttackWarmup(attackerB)) {
delayTicks(6)
}
runSync { attackCompat(attackerB, victim) }
delayTicks(2)
samples.count { !it.cancelled }.shouldBeExactly(1)
} finally {
HandlerList.unregisterAll(listener)
runSync {
fakeA?.removePlayer()
fakeB?.removePlayer()
victim.remove()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FishingRodVelocityIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeLessThan
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleFishingRodVelocity
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Entity
import org.bukkit.entity.FishHook
import org.bukkit.entity.Player
import org.bukkit.event.player.PlayerFishEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.util.Vector
import java.util.Random
import java.util.UUID
import java.util.concurrent.Callable
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Proxy
import kotlin.math.cos
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.sqrt
@OptIn(ExperimentalKotest::class)
class FishingRodVelocityIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleFishingRodVelocity not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
}).get()
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
fun setModuleRandomSeed(seed: Long) {
val field = module.javaClass.getDeclaredField("random")
field.isAccessible = true
field.set(module, Random(seed))
}
fun assertVectorClose(actual: Vector, expected: Vector, tolerance: Double) {
abs(actual.x - expected.x) shouldBeLessThan tolerance
abs(actual.y - expected.y) shouldBeLessThan tolerance
abs(actual.z - expected.z) shouldBeLessThan tolerance
}
fun createFishEvent(player: Player, hook: FishHook, state: PlayerFishEvent.State): PlayerFishEvent {
val ctors = PlayerFishEvent::class.java.constructors
for (ctor in ctors) {
val paramTypes = ctor.parameterTypes
val hasFishHookParam = paramTypes.any { FishHook::class.java.isAssignableFrom(it) }
val args = arrayOfNulls(paramTypes.size)
var ok = true
var hookAssigned = false
for (i in paramTypes.indices) {
val t = paramTypes[i]
args[i] = when {
Player::class.java.isAssignableFrom(t) -> player
FishHook::class.java.isAssignableFrom(t) -> {
hookAssigned = true
hook
}
Entity::class.java.isAssignableFrom(t) -> {
if (hasFishHookParam) {
// Treat as "caught" entity slot in modern signatures
null
} else if (!hookAssigned) {
// Legacy signatures sometimes use Entity for the hook
hookAssigned = true
hook
} else {
null
}
}
PlayerFishEvent.State::class.java.isAssignableFrom(t) -> state
t == Int::class.javaPrimitiveType -> 0
t == Boolean::class.javaPrimitiveType -> false
t.isEnum && t.name.endsWith("EquipmentSlot") -> {
// Prefer main-hand if present (modern signature)
runCatching { java.lang.Enum.valueOf(t as Class>, "HAND") }.getOrNull()
}
ItemStack::class.java.isAssignableFrom(t) -> ItemStack(Material.FISHING_ROD)
else -> null
}
// If we failed to provide a value for a primitive, this ctor won't work
if (args[i] == null && t.isPrimitive) {
ok = false
break
}
}
if (!ok) continue
try {
@Suppress("UNCHECKED_CAST")
val event = ctor.newInstance(*args) as PlayerFishEvent
// Validate that the event reports the expected hook; legacy signatures vary.
val hookObj = runCatching {
PlayerFishEvent::class.java.getMethod("getHook").invoke(event)
}.getOrNull()
val hookFromEvent = hookObj as? FishHook ?: continue
if (hookFromEvent.uniqueId != hook.uniqueId) continue
return event
} catch (_: Throwable) {
// Try next
}
}
error("No compatible PlayerFishEvent constructor found for this server version")
}
fun createFakeHook(player: Player): FishHook {
val id = UUID.randomUUID()
var velocity = Vector(0.0, 0.0, 0.0)
val handler = InvocationHandler { _, method, args ->
when (method.name) {
"getUniqueId" -> return@InvocationHandler id
"getVelocity" -> return@InvocationHandler velocity
"setVelocity" -> {
velocity = (args?.get(0) as? Vector) ?: velocity
return@InvocationHandler null
}
"isValid" -> return@InvocationHandler true
"remove" -> return@InvocationHandler null
"getWorld" -> return@InvocationHandler player.world
"getLocation" -> return@InvocationHandler player.location.clone()
}
return@InvocationHandler when (method.returnType) {
java.lang.Boolean.TYPE -> false
java.lang.Integer.TYPE -> 0
java.lang.Long.TYPE -> 0L
java.lang.Float.TYPE -> 0f
java.lang.Double.TYPE -> 0.0
java.lang.Void.TYPE -> null
else -> null
}
}
val interfaces = mutableListOf>(FishHook::class.java)
runCatching { Class.forName("org.bukkit.entity.Fish") }
.getOrNull()
?.takeIf { it.isInterface }
?.let { interfaces.add(it) }
return Proxy.newProxyInstance(
FishHook::class.java.classLoader,
interfaces.toTypedArray(),
handler
) as FishHook
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getWorld("world") ?: error("world not loaded")
val location = Location(world, 0.0, 120.0, 0.0, 45f, 10f)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
setModeset(player, "old")
module.reload()
}
}
afterSpec {
runSync { fakePlayer.removePlayer() }
}
beforeTest {
runSync {
setModeset(player, "old")
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.FISHING_ROD))
}
}
test("sets hook velocity to the 1.8 formula (deterministic random seed)") {
val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
runSync { player.launchProjectile(FishHook::class.java) }
} else {
createFakeHook(player)
}
try {
runSync {
// Keep everything in a single main-thread slice so hook physics cannot tick between steps (legacy servers are sensitive).
setModuleRandomSeed(0)
val event = createFishEvent(player, hook, PlayerFishEvent.State.FISHING)
module.onFishEvent(event)
// Mirror the module's float-heavy computation so Java 8/legacy servers match precisely.
val yaw = player.location.yaw.toDouble()
val pitch = player.location.pitch.toDouble()
val oldMaxVelocity = 0.4f.toDouble()
val piF = Math.PI.toFloat().toDouble()
val degF = 180.0f.toDouble()
var vx = -sin(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity
var vz = cos(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity
var vy = -sin(pitch / degF * piF) * oldMaxVelocity
val oldVelocityMultiplier = 1.5
val vectorLength = sqrt(vx * vx + vy * vy + vz * vz).toFloat().toDouble()
vx /= vectorLength
vy /= vectorLength
vz /= vectorLength
val rng = Random(0)
vx += rng.nextGaussian() * 0.007499999832361937
vy += rng.nextGaussian() * 0.007499999832361937
vz += rng.nextGaussian() * 0.007499999832361937
vx *= oldVelocityMultiplier
vy *= oldVelocityMultiplier
vz *= oldVelocityMultiplier
val expected = Vector(vx, vy, vz)
val actual = hook.velocity
assertVectorClose(actual, expected, 1e-6)
}
} finally {
if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
runSync { hook.remove() }
}
}
}
test("does not modify hook velocity for non-FISHING states") {
val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
runSync { player.launchProjectile(FishHook::class.java) }
} else {
createFakeHook(player)
}
try {
val nonFishing = PlayerFishEvent.State.values().firstOrNull { it != PlayerFishEvent.State.FISHING }
?: error("No non-FISHING PlayerFishEvent state available")
runSync {
val original = Vector(0.123, 0.456, -0.789)
hook.velocity = original
val baseline = hook.velocity
val event = createFishEvent(player, hook, nonFishing)
module.onFishEvent(event)
val actual = hook.velocity
assertVectorClose(actual, baseline, 1e-8)
}
} finally {
if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
runSync { hook.remove() }
}
}
}
test("1.14+ applies an extra gravity tick when the hook is not in water") {
if (!Reflector.versionIsNewerOrEqualTo(1, 14, 0)) return@test
val hookBaseline = runSync { player.launchProjectile(FishHook::class.java) }
val hookWithModule = runSync { player.launchProjectile(FishHook::class.java) }
try {
runSync {
// Keep both hooks colocated so vanilla physics is as close as possible
hookWithModule.teleport(hookBaseline.location)
// Schedule the module's per-tick gravity adjustment for hookWithModule
val event = createFishEvent(player, hookWithModule, PlayerFishEvent.State.FISHING)
Bukkit.getPluginManager().callEvent(event)
// Force the same starting velocity for both so we can compare deltas
val start = Vector(0.0, 0.2, 0.0)
hookBaseline.velocity = start
hookWithModule.velocity = start
}
// Wait for at least one tick of the module's gravity adjustment
delay(3 * 50L)
val yBaseline = runSync { hookBaseline.velocity.y }
val yWithModule = runSync { hookWithModule.velocity.y }
// Module subtracts an extra 0.01 from Y each tick (when not in water),
// so it should be noticeably more negative than the baseline hook.
(yWithModule < yBaseline - 0.005) shouldBe true
} finally {
runSync {
hookBaseline.remove()
hookWithModule.remove()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/GoldenAppleIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XMaterial
import com.cryptomorin.xseries.XPotion
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.longs.shouldBeBetween
import kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.Attribute
import org.bukkit.entity.Player
import org.bukkit.event.inventory.PrepareItemCraftEvent
import org.bukkit.event.player.PlayerItemConsumeEvent
import org.bukkit.inventory.CraftingInventory
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import kotlin.coroutines.resume
import kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat
@OptIn(ExperimentalKotest::class)
class GoldenAppleIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleGoldenApple.getInstance()
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val oldPotionEffects = ocm.config.getBoolean("old-golden-apples.old-potion-effects")
val normalCooldown = ocm.config.getLong("old-golden-apples.cooldown.normal")
val enchantedCooldown = ocm.config.getLong("old-golden-apples.cooldown.enchanted")
val sharedCooldown = ocm.config.getBoolean("old-golden-apples.cooldown.is-shared")
val crafting = ocm.config.getBoolean("old-golden-apples.enchanted-golden-apple-crafting")
val noConflict = ocm.config.getBoolean("old-golden-apples.no-conflict-mode")
try {
block()
} finally {
ocm.config.set("old-golden-apples.old-potion-effects", oldPotionEffects)
ocm.config.set("old-golden-apples.cooldown.normal", normalCooldown)
ocm.config.set("old-golden-apples.cooldown.enchanted", enchantedCooldown)
ocm.config.set("old-golden-apples.cooldown.is-shared", sharedCooldown)
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", crafting)
ocm.config.set("old-golden-apples.no-conflict-mode", noConflict)
module.reload()
ModuleLoader.toggleModules()
}
}
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin) {
action()
null
}.get()
}
}
fun setModeset(modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
fun callConsume(item: ItemStack): PlayerItemConsumeEvent {
val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 3 &&
Player::class.java.isAssignableFrom(params[0]) &&
ItemStack::class.java.isAssignableFrom(params[1]) &&
EquipmentSlot::class.java.isAssignableFrom(params[2])
}
val event = if (ctor != null) {
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
} else {
PlayerItemConsumeEvent(player, item)
}
Bukkit.getPluginManager().callEvent(event)
return event
}
fun prepareCraftResult(result: ItemStack): PrepareItemCraftEvent {
val openWorkbench = player.javaClass.getMethod(
"openWorkbench",
Location::class.java,
Boolean::class.javaPrimitiveType
)
val viewObj = openWorkbench.invoke(player, null, true) ?: error("Workbench view was null")
val getTopInventory = viewObj.javaClass.getMethod("getTopInventory")
val inventory = getTopInventory.invoke(viewObj) as CraftingInventory
inventory.result = result
val ctor = PrepareItemCraftEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 3 &&
CraftingInventory::class.java.isAssignableFrom(params[0]) &&
params[2] == Boolean::class.javaPrimitiveType
} ?: error("PrepareItemCraftEvent constructor not found")
return ctor.newInstance(inventory, viewObj, false) as PrepareItemCraftEvent
}
fun assertDuration(effect: PotionEffect?, expectedTicks: Int) {
effect.shouldNotBe(null)
val duration = effect!!.duration
duration.shouldBeGreaterThanOrEqual(expectedTicks - 10)
duration.shouldBeLessThanOrEqual(expectedTicks)
}
suspend fun waitForEffects(ticks: Long = 2L) {
suspendCancellableCoroutine { continuation ->
Bukkit.getScheduler().runTaskLater(testPlugin, Runnable {
if (continuation.isActive) {
continuation.resume(Unit)
}
}, ticks)
}
}
fun maxHealthAttribute(): Attribute {
return XAttribute.MAX_HEALTH.get() ?: error("Max health attribute not available")
}
fun enchantedAppleItem(): ItemStack {
return XMaterial.ENCHANTED_GOLDEN_APPLE.parseItem()
?: error("Enchanted golden apple item not available")
}
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val location = Location(world, 0.0, 100.0, 0.0)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0
player.isInvulnerable = false
player.isOp = true
setModeset("old")
}
}
afterSpec {
runSync {
fakePlayer.removePlayer()
}
}
beforeTest {
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.health = player.getAttribute(maxHealthAttribute())!!.value
player.foodLevel = 20
setModeset("old")
module.reload()
}
}
context("Potion Effects") {
test("golden apple applies configured effects") {
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
waitForEffects()
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)
assertDuration(regeneration, 5 * 20)
regeneration?.amplifier shouldBe 1
assertDuration(absorption, 120 * 20)
absorption?.amplifier shouldBe 0
}
test("enchanted golden apple applies configured effects") {
player.inventory.setItemInMainHand(enchantedAppleItem())
callConsume(player.inventory.itemInMainHand)
waitForEffects()
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)
val resistance = player.getPotionEffectCompat(XPotion.RESISTANCE.get()!!)
val fireResistance = player.getPotionEffectCompat(PotionEffectType.FIRE_RESISTANCE)
assertDuration(regeneration, 30 * 20)
regeneration?.amplifier shouldBe 4
assertDuration(absorption, 120 * 20)
absorption?.amplifier shouldBe 0
assertDuration(resistance, 300 * 20)
resistance?.amplifier shouldBe 0
assertDuration(fireResistance, 300 * 20)
fireResistance?.amplifier shouldBe 0
}
test("higher amplifier replaces existing effect") {
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 0))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
waitForEffects()
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
regeneration?.amplifier shouldBe 1
assertDuration(regeneration, 5 * 20)
}
test("same amplifier with longer duration refreshes effect") {
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 50, 1))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
waitForEffects()
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
regeneration?.amplifier shouldBe 1
assertDuration(regeneration, 5 * 20)
}
test("lower amplifier does not override existing effect") {
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 3))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
waitForEffects()
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
regeneration?.amplifier shouldBe 3
}
test("old-potion-effects disabled leaves effects unchanged") {
withConfig {
ocm.config.set("old-golden-apples.old-potion-effects", false)
module.reload()
player.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 200, 0))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
waitForEffects()
player.getPotionEffectCompat(PotionEffectType.SPEED).shouldNotBe(null)
player.getPotionEffectCompat(PotionEffectType.REGENERATION).shouldBe(null)
player.getPotionEffectCompat(PotionEffectType.ABSORPTION).shouldBe(null)
}
}
}
context("Cooldowns") {
test("repeated consumption is blocked by cooldown") {
withConfig {
ocm.config.set("old-golden-apples.cooldown.normal", 60)
ocm.config.set("old-golden-apples.cooldown.enchanted", 0)
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
val first = callConsume(player.inventory.itemInMainHand)
first.isCancelled shouldBe false
val second = callConsume(player.inventory.itemInMainHand)
second.isCancelled shouldBe true
}
}
test("shared cooldown blocks other apple type") {
withConfig {
ocm.config.set("old-golden-apples.cooldown.normal", 60)
ocm.config.set("old-golden-apples.cooldown.enchanted", 60)
ocm.config.set("old-golden-apples.cooldown.is-shared", true)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
player.inventory.setItemInMainHand(enchantedAppleItem())
val enchantedEvent = callConsume(player.inventory.itemInMainHand)
enchantedEvent.isCancelled shouldBe true
}
}
test("separate cooldown allows other apple type") {
withConfig {
ocm.config.set("old-golden-apples.cooldown.normal", 60)
ocm.config.set("old-golden-apples.cooldown.enchanted", 60)
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
player.inventory.setItemInMainHand(enchantedAppleItem())
val enchantedEvent = callConsume(player.inventory.itemInMainHand)
enchantedEvent.isCancelled shouldBe false
}
}
test("cooldown getters return remaining seconds") {
withConfig {
ocm.config.set("old-golden-apples.cooldown.normal", 5)
ocm.config.set("old-golden-apples.cooldown.enchanted", 5)
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsume(player.inventory.itemInMainHand)
module.getGappleCooldown(player.uniqueId).shouldBeBetween(1, 5)
player.inventory.setItemInMainHand(enchantedAppleItem())
callConsume(player.inventory.itemInMainHand)
module.getNappleCooldown(player.uniqueId).shouldBeBetween(1, 5)
}
}
}
context("Crafting") {
test("enchanted golden apple crafting is blocked when disabled") {
withConfig {
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", false)
ocm.config.set("old-golden-apples.no-conflict-mode", false)
module.reload()
val event = prepareCraftResult(enchantedAppleItem())
Bukkit.getPluginManager().callEvent(event)
event.inventory.result.shouldBe(null)
player.closeInventory()
}
}
test("no-conflict-mode preserves crafting result") {
withConfig {
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", false)
ocm.config.set("old-golden-apples.no-conflict-mode", true)
module.reload()
val event = prepareCraftResult(enchantedAppleItem())
Bukkit.getPluginManager().callEvent(event)
event.inventory.result.shouldNotBe(null)
player.closeInventory()
}
}
test("unknown modeset disables crafting") {
withConfig {
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", true)
ocm.config.set("old-golden-apples.no-conflict-mode", false)
module.reload()
setModeset("missing")
val event = prepareCraftResult(enchantedAppleItem())
Bukkit.getPluginManager().callEvent(event)
event.inventory.result.shouldBe(null)
player.closeInventory()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InGameTester.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XEnchantment
import com.cryptomorin.xseries.XPotion
import kernitus.plugin.OldCombatMechanics.TesterUtils.assertEquals
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.getOldSharpnessDamage
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.isCriticalHit1_8
import kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils.getDamageAfterArmour1_8
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getDamage
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.AttributeModifier
import org.bukkit.command.CommandSender
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionEffect
import java.util.*
import java.util.function.Consumer
import kotlin.math.max
class InGameTester(private val plugin: JavaPlugin) {
private var tally: Tally? = null
private var sender: CommandSender? = null
private lateinit var attacker: Player
private lateinit var defender: Player
private lateinit var fakeAttacker: FakePlayer
private lateinit var fakeDefender: FakePlayer
private val testQueue: Queue = ArrayDeque()
/**
* Perform all tests using the two specified players
*/
fun performTests(sender: CommandSender?, location: Location) {
plugin.logger.info("PERFORMING THE TESTS")
this.sender = sender
fakeAttacker = FakePlayer(plugin)
plugin.logger.info("FAKE")
fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))
plugin.logger.info("FAKE2")
fakeDefender = FakePlayer(plugin)
val defenderLocation = location.add(0.0, 0.0, 2.0)
fakeDefender.spawn(defenderLocation)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))
// Turn defender to face attacker
defenderLocation.yaw = 180f
defenderLocation.pitch = 0f
defender.teleport(defenderLocation)
// modeset of attacker takes precedence
var playerData = getPlayerData(attacker.uniqueId)
playerData.setModesetForWorld(attacker.world.uid, "old")
setPlayerData(attacker.uniqueId, playerData)
playerData = getPlayerData(defender.uniqueId)
playerData.setModesetForWorld(defender.world.uid, "new")
setPlayerData(defender.uniqueId, playerData)
beforeAll()
tally = Tally()
// Queue all tests
//runAttacks(new ItemStack[]{}, () -> {}); // with no armour
testArmour()
//testEnchantedMelee(new ItemStack[]{}, () -> {});
// Run all tests in the queue
runQueuedTests()
}
private fun runAttacks(armour: Array, preparations: Runnable) {
//testMelee(armour, preparations);
testEnchantedMelee(armour, preparations)
testOverdamage(armour, preparations)
}
private fun testArmour() {
val materials = arrayOf("LEATHER", "CHAINMAIL", "GOLDEN", "IRON", "DIAMOND", "NETHERITE")
val slots = arrayOf("BOOTS", "LEGGINGS", "CHESTPLATE", "HELMET")
val random = Random(System.currentTimeMillis())
val armourContents = Array(4) { i ->
val slot = slots[i]
// Pick a random material for each slot
val material = materials[random.nextInt(materials.size)]
val itemStack = ItemStack(Material.valueOf("${material}_$slot"))
// Apply enchantment to the armour piece
itemStack.addUnsafeEnchantment(XEnchantment.PROTECTION.get()!!, 50)
itemStack
}
runAttacks(armourContents) {
defender.inventory.setArmorContents(armourContents)
// Test status effects on defence: resistance, fire resistance, absorption
defender.addPotionEffect(PotionEffect(XPotion.RESISTANCE.potionEffectType!!, 10, 1))
fakeDefender.doBlocking()
}
}
private fun testEnchantedMelee(armour: Array, preparations: Runnable) {
for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {
val weapon = ItemStack(weaponType)
// only axe and sword can have sharpness
try {
weapon.addEnchantment(XEnchantment.SHARPNESS.get()!!, 3)
} catch (ignored: IllegalArgumentException) {
}
val message = weaponType.name + " Sharpness 3"
queueAttack(OCMTest(weapon, armour, 2, message) {
preparations.run()
defender.maximumNoDamageTicks = 0
attacker.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 10, 0, false))
attacker.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 10, -1, false))
plugin.logger.info("TESTING WEAPON $weaponType")
attacker.fallDistance = 2f // Crit
})
}
}
private fun testMelee(armour: Array, preparations: Runnable) {
for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {
val weapon = ItemStack(weaponType)
queueAttack(OCMTest(weapon, armour, 1, weaponType.name) {
preparations.run()
defender.maximumNoDamageTicks = 0
})
}
}
private fun testOverdamage(armour: Array, preparations: Runnable) {
// 1, 5, 6, 7, 3, 8 according to OCM
// 1, 4, 5, 6, 2, 7 according to 1.9+
val weapons = arrayOf(
Material.WOODEN_HOE,
Material.WOODEN_SWORD,
Material.STONE_SWORD,
Material.IRON_SWORD,
Material.WOODEN_PICKAXE,
Material.DIAMOND_SWORD
)
for (weaponType in weapons) {
val weapon = ItemStack(weaponType)
queueAttack(OCMTest(weapon, armour, 3, weaponType.name, Runnable {
preparations.run()
defender.maximumNoDamageTicks = 30
}))
}
}
private fun queueAttack(test: OCMTest) {
testQueue.add(test)
}
private fun calculateAttackDamage(weapon: ItemStack): Double {
val weaponType = weapon.type
// Attack components order: (Base + Potion effects, scaled by attack delay) + Critical Hit + (Enchantments, scaled by attack delay)
// Hurt components order: Overdamage - Armour Effects
var expectedDamage = getDamage(weaponType)
// Weakness effect, 1.8: -0.5
// We ignore the level as there is only one level of weakness potion
val weaknessAddend = if (attacker.hasPotionEffect(XPotion.WEAKNESS.get()!!)) -0.5 else 0.0
// Strength effect
// 1.8: +130% for each strength level
val strength = attacker.getPotionEffectCompat(XPotion.STRENGTH.get()!!)
if (strength != null) expectedDamage += (strength.amplifier + 1) * 1.3 * expectedDamage
expectedDamage += weaknessAddend
// Take into account damage reduction because of cooldown
val attackCooldown = defender.attackCooldown
expectedDamage *= (0.2f + attackCooldown * attackCooldown * 0.8f).toDouble()
// Critical hit
if (isCriticalHit1_8(attacker)) {
expectedDamage *= 1.5
}
// Weapon Enchantments
var sharpnessDamage = getOldSharpnessDamage(weapon.getEnchantmentLevel(XEnchantment.SHARPNESS.get()!!))
sharpnessDamage *= attackCooldown.toDouble() // Scale by attack cooldown strength
expectedDamage += sharpnessDamage
return expectedDamage
}
private fun wasFakeOverdamage(weapon: ItemStack): Boolean {
val weaponDamage = calculateAttackDamage(weapon)
val lastDamage = defender.lastDamage
return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&
weaponDamage <= lastDamage
}
private fun wasOverdamaged(rawWeaponDamage: Double): Boolean {
val lastDamage = defender.lastDamage
return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&
rawWeaponDamage > lastDamage
}
private fun calculateExpectedDamage(weapon: ItemStack, armourContents: Array): Float {
var expectedDamage = calculateAttackDamage(weapon)
if (wasOverdamaged(expectedDamage)) {
val lastDamage = defender.lastDamage
plugin.logger.info("Overdamaged: " + expectedDamage + " - " + lastDamage + " = " + (expectedDamage - lastDamage))
expectedDamage -= lastDamage
}
if (defender.isBlocking) {
plugin.logger.info("DEFENDER IS BLOCKING $expectedDamage")
expectedDamage -= max(0.0, (expectedDamage - 1)) * 0.5
plugin.logger.info("AFTER BLOCK $expectedDamage")
}
expectedDamage = getDamageAfterArmour1_8(
defender,
expectedDamage,
armourContents,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
false
)
return expectedDamage.toFloat()
}
private fun runQueuedTests() {
plugin.logger.info("Running " + testQueue.size + " tests")
val listener: Listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR)
fun onEvent(e: EntityDamageByEntityEvent) {
val damager = e.damager
if (damager.uniqueId !== attacker.uniqueId ||
e.entity.uniqueId !== defender.uniqueId
) return
val weapon = (damager as Player).inventory.itemInMainHand
val weaponType = weapon.type
var test = testQueue.remove()
var expectedWeapon = test.weapon
var expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)
while (weaponType != expectedWeapon.type) {
expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)
plugin.logger.info("SKIPPED " + expectedWeapon.type + " Expected Damage: " + expectedDamage)
if (expectedDamage == 0f) tally!!.passed()
else tally!!.failed()
test = testQueue.remove()
expectedWeapon = test.weapon
}
if (wasFakeOverdamage(weapon) && e.isCancelled) {
plugin.logger.info("PASSED Fake overdamage " + expectedDamage + " < " + (e.entity as LivingEntity).lastDamage)
tally!!.passed()
} else {
val weaponMessage = "E: " + expectedWeapon.type.name + " A: " + weaponType.name
assertEquals(
expectedDamage, e.finalDamage.toFloat(),
tally!!, weaponMessage, sender!!
)
}
}
}
Bukkit.getServer().pluginManager.registerEvents(listener, plugin)
val testCount = testQueue.size.toLong()
var attackDelay: Long = 0
for (test in testQueue) {
attackDelay += test.attackDelay
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
beforeEach()
test.preparations.run()
preparePlayer(test.weapon)
attackCompat(attacker, defender)
afterEach()
}, attackDelay)
}
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
afterAll(testCount)
EntityDamageByEntityEvent.getHandlerList().unregister(listener)
}, attackDelay + 1)
}
private fun beforeAll() {
plugin.logger.info("Running before all")
for (player in listOfNotNull(attacker, defender)) {
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0 // remove spawn invulnerability
player.isInvulnerable = false
}
}
private fun afterAll(testCount: Long) {
fakeAttacker.removePlayer()
fakeDefender.removePlayer()
val missed = testCount - tally!!.total
val message = String.format(
"Passed: %d Failed: %d Total: %d Missed: %d",
tally!!.passed,
tally!!.failed,
tally!!.total,
missed
)
plugin.logger.info(message)
}
private fun beforeEach() {
for (player in listOfNotNull(attacker, defender)) {
player.inventory.clear()
player.exhaustion = 0f
player.health = 20.0
}
}
private fun preparePlayer(weapon: ItemStack) {
if (weapon.hasItemMeta()) {
val meta = weapon.itemMeta
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()
if (attackSpeedAttribute != null) {
addAttributeModifierCompat(meta!!, attackSpeedAttribute, speedModifier)
}
weapon.setItemMeta(meta)
}
attacker.inventory.setItemInMainHand(weapon)
attacker.updateInventory()
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val armourAttribute = XAttribute.ARMOR.get()
val ai = attackDamageAttribute?.let { attacker.getAttribute(it) }
val defenderArmour = armourAttribute?.let { defender.getAttribute(it) }
if (attackDamageAttribute != null && ai != null) {
getDefaultAttributeModifiersCompat(weapon, EquipmentSlot.HAND, attackDamageAttribute).forEach(
Consumer { am: AttributeModifier? ->
ai.removeModifier(am!!)
ai.addModifier(am)
})
}
val armourContents = defender.inventory.armorContents
plugin.logger.info(
"Armour: " + Arrays.stream(armourContents).filter { obj: ItemStack? -> Objects.nonNull(obj) }
.map { `is`: ItemStack -> `is`.type.name }
.reduce { a: String, b: String -> "$a, $b" }
.orElse("none")
)
for (i in armourContents.indices) {
val itemStack = armourContents[i] ?: continue
val type = itemStack.type
val slot =
arrayOf(
EquipmentSlot.FEET,
EquipmentSlot.LEGS,
EquipmentSlot.CHEST,
EquipmentSlot.HEAD
)[i]
if (armourAttribute != null && defenderArmour != null) {
for (attributeModifier in getDefaultAttributeModifiersCompat(itemStack, slot, armourAttribute)) {
defenderArmour.removeModifier(attributeModifier)
defenderArmour.addModifier(attributeModifier)
}
}
}
}
private fun afterEach() {
for (player in listOfNotNull(attacker, defender)) {
player.exhaustion = 0f
player.health = 20.0
}
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InGameTesterIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.doubles.shouldBeExactly
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.EntityType
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class InGameTesterIntegrationTest :
StringSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extension(MainThreadDispatcherExtension(plugin))
lateinit var attacker: Player
lateinit var defender: Player
lateinit var fakeAttacker: FakePlayer
lateinit var fakeDefender: FakePlayer
fun runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(plugin, Callable { action() }).get()
}
fun preparePlayers() {
println("Preparing players")
val world = Bukkit.getServer().getWorld("world")
// TODO might need to specify server superflat?
val location = Location(world, 0.0, 100.0, 0.0)
fakeAttacker = FakePlayer(plugin)
fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))
fakeDefender = FakePlayer(plugin)
val defenderLocation = location.add(0.0, 0.0, 2.0)
fakeDefender.spawn(defenderLocation)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))
// Turn defender to face attacker
defenderLocation.yaw = 180f
defenderLocation.pitch = 0f
defender.teleport(defenderLocation)
// modeset of attacker takes precedence
var playerData = getPlayerData(attacker.uniqueId)
playerData.setModesetForWorld(attacker.world.uid, "old")
setPlayerData(attacker.uniqueId, playerData)
playerData = getPlayerData(defender.uniqueId)
playerData.setModesetForWorld(defender.world.uid, "new")
setPlayerData(defender.uniqueId, playerData)
}
beforeSpec {
plugin.logger.info("Running before all")
runSync { preparePlayers() }
}
beforeTest {
runSync {
for (player in listOfNotNull(attacker, defender)) {
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0 // remove spawn invulnerability
player.isInvulnerable = false
}
}
}
afterSpec {
plugin.logger.info("Running after all")
runSync {
fakeAttacker.removePlayer()
fakeDefender.removePlayer()
}
}
"test melee attacks" {
println("Testing melee attack")
val netheriteSword = runCatching { Material.valueOf("NETHERITE_SWORD") }.getOrNull()
val weapon = ItemStack(netheriteSword ?: Material.STONE_SWORD)
val victim =
runSync {
attacker.world.spawnEntity(attacker.location.clone().add(1.5, 0.0, 0.0), EntityType.ZOMBIE) as LivingEntity
}
try {
runSync {
attacker.inventory.setItemInMainHand(weapon)
attacker.updateInventory()
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
}
var damageEvents = 0
var sawExpectedWeaponAtDamage = false
var sawMeleeCause = false
var sawPositiveUncancelledDamage = false
val listener =
object : Listener {
@EventHandler(priority = EventPriority.MONITOR)
fun onEntityDamageByEntity(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId != attacker.uniqueId || event.entity.uniqueId != victim.uniqueId) {
return
}
damageEvents += 1
sawExpectedWeaponAtDamage = (attacker.inventory.itemInMainHand.type == weapon.type)
sawMeleeCause =
event.cause == EntityDamageEvent.DamageCause.ENTITY_ATTACK ||
event.cause == EntityDamageEvent.DamageCause.ENTITY_SWEEP_ATTACK
if (!event.isCancelled && event.finalDamage > 0.0) {
sawPositiveUncancelledDamage = true
}
}
}
runSync { Bukkit.getPluginManager().registerEvents(listener, plugin) }
val victimStartHealth = runSync { victim.health }
var minimumVictimHealth = victimStartHealth
try {
repeat(12) {
val damaged =
runSync {
attackCompat(attacker, victim)
minimumVictimHealth = minOf(minimumVictimHealth, victim.health)
minimumVictimHealth < victimStartHealth && sawPositiveUncancelledDamage
}
if (damaged) {
return@repeat
}
delay(2 * 50L)
}
} finally {
runSync { EntityDamageByEntityEvent.getHandlerList().unregister(listener) }
}
repeat(4) {
runSync {
minimumVictimHealth = minOf(minimumVictimHealth, victim.health)
}
if (minimumVictimHealth < victimStartHealth) {
return@repeat
}
delay(50L)
}
@Suppress("DEPRECATION") // Deprecated API kept for older server compatibility in tests.
runSync { attacker.health } shouldBeExactly runSync { attacker.maxHealth }
sawPositiveUncancelledDamage shouldBe true
(minimumVictimHealth < victimStartHealth) shouldBe true
(damageEvents > 0) shouldBe true
sawExpectedWeaponAtDamage shouldBe true
sawMeleeCause shouldBe true
} finally {
runSync { victim.remove() }
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/InvulnerabilityDamageIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XMaterial
import com.cryptomorin.xseries.XPotion
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.AttributeModifier
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerItemConsumeEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.PotionMeta
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionData
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.potion.PotionType
import kotlinx.coroutines.delay
import java.util.UUID
import java.util.concurrent.Callable
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlin.math.abs
@OptIn(ExperimentalKotest::class)
class InvulnerabilityDamageIntegrationTest : FunSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(plugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(plugin, Callable {
action()
null
}).get()
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(player: Player, item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
val expectedAmounts = modifiers
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }
.map { it.amount }
val knownWeaponAmounts = NewWeaponDamage.values()
.map { it.damage.toDouble() - 1.0 }
.filter { it > 0.0 }
.toSet()
fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001
val existingModifiers = attackAttribute.modifiers.toList()
existingModifiers
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }
.filter { modifier ->
knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&
expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }
}
.forEach { attackAttribute.removeModifier(it) }
modifiers.forEach { modifier ->
val alreadyApplied = attackAttribute.modifiers.any {
it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)
}
if (!alreadyApplied) {
attackAttribute.addModifier(modifier)
}
}
}
fun equip(player: Player, item: ItemStack) {
prepareWeapon(item)
player.inventory.setItemInMainHand(item)
applyAttackDamageModifiers(player, item)
player.updateInventory()
}
fun spawnAttacker(location: Location): Pair {
val fake = FakePlayer(plugin)
fake.spawn(location)
val player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
return fake to player
}
fun spawnVictim(location: Location): LivingEntity {
val world = location.world ?: error("World missing for victim spawn")
return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
fun createWeaknessPotion(): ItemStack {
val item = ItemStack(Material.POTION)
val meta = item.itemMeta as PotionMeta
try {
meta.basePotionType = PotionType.WEAKNESS
} catch (e: NoSuchMethodError) {
@Suppress("DEPRECATION") // Required for legacy server compatibility.
meta.basePotionData = PotionData(PotionType.WEAKNESS, false, false)
}
item.itemMeta = meta
return item
}
fun consumeWeaknessPotion(player: Player): PotionEffect {
val item = createWeaknessPotion()
val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 3 &&
Player::class.java.isAssignableFrom(params[0]) &&
ItemStack::class.java.isAssignableFrom(params[1]) &&
EquipmentSlot::class.java.isAssignableFrom(params[2])
}
val event = if (ctor != null) {
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
} else {
PlayerItemConsumeEvent(player, item)
}
Bukkit.getPluginManager().callEvent(event)
val meta = event.item.itemMeta as PotionMeta
return meta.customEffects.firstOrNull { it.type == PotionEffectType.WEAKNESS }
?: error("Weakness effect missing from potion meta")
}
test("second hit in the same tick should still fire inside invulnerability").config(
enabled = false
) {
// Disabled: current vanilla pipeline drops same-tick follow-up hits during invulnerability.
// We will revisit once the intended behaviour is defined across versions.
val events = mutableListOf()
lateinit var attacker1: Player
lateinit var attacker2: Player
var victim: LivingEntity? = null
var fake1: FakePlayer? = null
var fake2: FakePlayer? = null
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
(event.damager.uniqueId == attacker1.uniqueId || event.damager.uniqueId == attacker2.uniqueId)
) {
events.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attacker1Location = Location(world, 0.0, 100.0, 0.0)
val attacker2Location = Location(world, 0.0, 100.0, 2.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnAttacker(attacker1Location)
val (fakeB, playerB) = spawnAttacker(attacker2Location)
fake1 = fakeA
fake2 = fakeB
attacker1 = playerA
attacker2 = playerB
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
Bukkit.getPluginManager().registerEvents(listener, plugin)
equip(attacker1, ItemStack(Material.DIAMOND_SWORD))
equip(attacker2, ItemStack(Material.STONE_SWORD))
attackCompat(attacker1, spawnedVictim)
attackCompat(attacker2, spawnedVictim)
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attacker1Damage = attackDamageAttribute?.let { attacker1.getAttribute(it)?.value }
val attacker2Damage = attackDamageAttribute?.let { attacker2.getAttribute(it)?.value }
plugin.logger.info(
"Invuln same-tick debug: events=${events.size} " +
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
"attacker1Damage=$attacker1Damage attacker2Damage=$attacker2Damage"
)
}
delayTicks(3)
runSync {
val currentVictim = checkNotNull(victim)
plugin.logger.info(
"Invuln same-tick debug (post): events=${events.size} " +
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
)
}
events.size.shouldBeExactly(2)
} finally {
HandlerList.unregisterAll(listener)
runSync {
fake1?.removePlayer()
fake2?.removePlayer()
victim?.remove()
}
}
}
test("slightly higher base damage inside invulnerability should fire")
.config(enabled = false) {
// Ignored for now: behaviour differs across versions and is not addressed yet.
val events = mutableListOf()
lateinit var attacker: Player
var victim: LivingEntity? = null
var fake: FakePlayer? = null
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
events.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnAttacker(attackerLocation)
fake = fakeA
attacker = playerA
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
Bukkit.getPluginManager().registerEvents(listener, plugin)
val woodenSword = XMaterial.WOODEN_SWORD.parseItem()
?: error("WOODEN_SWORD material not available")
equip(attacker, woodenSword)
attackCompat(attacker, spawnedVictim)
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
plugin.logger.info(
"Invuln overdamage debug (first hit): events=${events.size} " +
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
"attackerDamage=$attackerDamage"
)
}
delayTicks(2)
runSync {
val currentVictim = checkNotNull(victim)
val firstDamage = events.firstOrNull()?.damage
?: error("Expected a damage event from the first hit")
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
currentVictim.lastDamage = firstDamage
val stoneSword = XMaterial.STONE_SWORD.parseItem()
?: error("STONE_SWORD material not available")
equip(attacker, stoneSword)
attackCompat(attacker, currentVictim)
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
plugin.logger.info(
"Invuln overdamage debug (second hit): events=${events.size} " +
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
"attackerDamage=$attackerDamage firstDamage=$firstDamage"
)
}
delayTicks(2)
runSync {
val currentVictim = checkNotNull(victim)
plugin.logger.info(
"Invuln overdamage debug (post): events=${events.size} " +
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
)
}
events.size.shouldBeExactly(2)
} finally {
HandlerList.unregisterAll(listener)
runSync {
fake?.removePlayer()
victim?.remove()
}
}
}
test("clearly higher base damage inside invulnerability should fire") {
val events = mutableListOf()
lateinit var attacker: Player
var victim: LivingEntity? = null
var fake: FakePlayer? = null
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
events.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnAttacker(attackerLocation)
fake = fakeA
attacker = playerA
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
Bukkit.getPluginManager().registerEvents(listener, plugin)
val woodenSword = XMaterial.WOODEN_SWORD.parseItem()
?: error("WOODEN_SWORD material not available")
equip(attacker, woodenSword)
attackCompat(attacker, spawnedVictim)
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
plugin.logger.info(
"Invuln overdamage (iron) debug (first hit): events=${events.size} " +
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
"attackerDamage=$attackerDamage"
)
}
delayTicks(2)
runSync {
val currentVictim = checkNotNull(victim)
val firstDamage = events.firstOrNull()?.damage
?: error("Expected a damage event from the first hit")
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
currentVictim.lastDamage = firstDamage
val ironSword = XMaterial.IRON_SWORD.parseItem()
?: error("IRON_SWORD material not available")
equip(attacker, ironSword)
attackCompat(attacker, currentVictim)
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
plugin.logger.info(
"Invuln overdamage (iron) debug (second hit): events=${events.size} " +
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
"attackerDamage=$attackerDamage firstDamage=$firstDamage"
)
}
delayTicks(2)
runSync {
val currentVictim = checkNotNull(victim)
plugin.logger.info(
"Invuln overdamage (iron) debug (post): events=${events.size} " +
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
)
}
events.size.shouldBeExactly(2)
} finally {
HandlerList.unregisterAll(listener)
runSync {
fake?.removePlayer()
victim?.remove()
}
}
}
test("zero vanilla damage should still fire after lastDamage reset").config(
enabled = false
) {
// Disabled: behaviour diverges across versions; we will revisit once vanilla expectations are finalised.
val events = mutableListOf()
lateinit var attacker: Player
var victim: LivingEntity? = null
var fake: FakePlayer? = null
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId == currentVictim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
events.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnAttacker(attackerLocation)
fake = fakeA
attacker = playerA
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
Bukkit.getPluginManager().registerEvents(listener, plugin)
equip(attacker, ItemStack(Material.DIAMOND_SWORD))
attackCompat(attacker, spawnedVictim)
}
delayTicks(2) // allow MONITOR handler to set lastDamage to 0
runSync {
val currentVictim = checkNotNull(victim)
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
currentVictim.lastDamage.shouldBe(0.0)
val effect = consumeWeaknessPotion(attacker)
attacker.addPotionEffect(effect, true)
val woodenSword = XMaterial.WOODEN_SWORD.parseItem() ?: ItemStack(Material.STONE_SWORD)
equip(attacker, woodenSword)
attackCompat(attacker, currentVictim)
}
delayTicks(3)
events.size.shouldBeExactly(2)
} finally {
HandlerList.unregisterAll(listener)
runSync {
fake?.removePlayer()
victim?.remove()
}
}
}
test("weakness should not store negative last damage values") {
var attacker: Player? = null
var victim: LivingEntity? = null
var fake: FakePlayer? = null
var edbelListener: Listener? = null
var lastDamagesField: java.lang.reflect.Field? = null
var damageEvents = 0
val damageListener = object : Listener {
@EventHandler(ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
val currentVictim = victim ?: return
val currentAttacker = attacker ?: return
if (event.entity.uniqueId != currentVictim.uniqueId) return
if (event.damager.uniqueId != currentAttacker.uniqueId) return
damageEvents += 1
}
}
val oldWeaknessModifier = ocm.config.getDouble("old-potion-effects.weakness.modifier")
val oldWeaknessMultiplier = ocm.config.getBoolean("old-potion-effects.weakness.multiplier")
try {
runSync {
ocm.config.set("old-potion-effects.weakness.modifier", -10.0)
ocm.config.set("old-potion-effects.weakness.multiplier", false)
ocm.saveConfig()
Config.reload()
edbelListener = HandlerList.getRegisteredListeners(ocm)
.map { it.listener }
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
?: error("EntityDamageByEntityListener not registered")
lastDamagesField = edbelListener?.javaClass?.getDeclaredField("lastDamages")?.apply {
isAccessible = true
} ?: error("Failed to resolve lastDamages field")
val world = checkNotNull(Bukkit.getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val (fakeA, playerA) = spawnAttacker(attackerLocation)
fake = fakeA
attacker = playerA
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
Bukkit.getPluginManager().registerEvents(damageListener, plugin)
val effect = consumeWeaknessPotion(playerA)
playerA.addPotionEffect(effect, true)
equip(playerA, ItemStack(Material.IRON_SWORD))
attackCompat(playerA, spawnedVictim)
val currentVictim = checkNotNull(victim)
@Suppress("UNCHECKED_CAST")
val lastDamages = lastDamagesField?.get(edbelListener) as Map
val stored = lastDamages[currentVictim.uniqueId]
?: error("No stored last damage for victim (events=$damageEvents)")
damageEvents.shouldBeExactly(1)
stored.shouldBeGreaterThanOrEqual(0.0)
}
} finally {
HandlerList.unregisterAll(damageListener)
runSync {
ocm.config.set("old-potion-effects.weakness.modifier", oldWeaknessModifier)
ocm.config.set("old-potion-effects.weakness.multiplier", oldWeaknessMultiplier)
ocm.saveConfig()
Config.reload()
fake?.removePlayer()
victim?.remove()
}
}
}
test("environmental damage above baseline should apply during invulnerability") {
var victim: LivingEntity? = null
var edbelListener: Listener? = null
var lastDamagesField: java.lang.reflect.Field? = null
var capturedEvent: EntityDamageEvent? = null
val eventListener = object : Listener {
@EventHandler(ignoreCancelled = false)
fun onDamage(event: EntityDamageEvent) {
val currentVictim = victim ?: return
if (event.entity.uniqueId != currentVictim.uniqueId) return
if (event is EntityDamageByEntityEvent) return
capturedEvent = event
}
}
try {
runSync {
edbelListener = HandlerList.getRegisteredListeners(ocm)
.map { it.listener }
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
?: error("EntityDamageByEntityListener not registered")
lastDamagesField = edbelListener?.javaClass?.getDeclaredField("lastDamages")?.apply {
isAccessible = true
} ?: error("Failed to resolve lastDamages field")
val world = checkNotNull(Bukkit.getWorld("world"))
val victimLocation = Location(world, 1.2, 100.0, 0.0)
val spawnedVictim = spawnVictim(victimLocation)
victim = spawnedVictim
spawnedVictim.maximumNoDamageTicks = 20
spawnedVictim.noDamageTicks = 20
spawnedVictim.lastDamage = 0.0
@Suppress("UNCHECKED_CAST")
val lastDamages = lastDamagesField?.get(edbelListener) as MutableMap
lastDamages[spawnedVictim.uniqueId] = 5.0
Bukkit.getPluginManager().registerEvents(eventListener, plugin)
val event = EntityDamageEvent(
spawnedVictim,
EntityDamageEvent.DamageCause.FALL,
12.0
)
Bukkit.getPluginManager().callEvent(event)
}
runSync {
val event = checkNotNull(capturedEvent)
event.isCancelled.shouldBe(false)
event.damage.shouldBe(7.0)
}
} finally {
HandlerList.unregisterAll(eventListener)
runSync {
@Suppress("UNCHECKED_CAST")
val lastDamages = lastDamagesField?.get(edbelListener) as? MutableMap
lastDamages?.remove(victim?.uniqueId)
victim?.remove()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/KotestRunner.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.github.ajalt.mordant.TermColors
import io.kotest.common.ExperimentalKotest
import io.kotest.common.KotestInternal
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.core.extensions.TestCaseExtension
import io.kotest.core.spec.IsolationMode
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestCaseOrder
import io.kotest.core.test.TestResult
import io.kotest.engine.TestEngineLauncher
import io.kotest.engine.listener.AbstractTestEngineListener
import io.kotest.engine.listener.CompositeTestEngineListener
import io.kotest.engine.listener.EnhancedConsoleTestEngineListener
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
@OptIn(ExperimentalKotest::class)
object KotestProjectConfig : AbstractProjectConfig() {
override val isolationMode = IsolationMode.SingleInstance
override val concurrentSpecs = 1
override val concurrentTests = 1
override val testCaseOrder = TestCaseOrder.Sequential
}
class BukkitMainThreadDispatcher(
private val plugin: JavaPlugin,
) : CoroutineDispatcher() {
override fun dispatch(
context: CoroutineContext,
block: Runnable,
) {
Bukkit.getScheduler().runTask(plugin, block)
}
}
class MainThreadDispatcherExtension(
private val plugin: JavaPlugin,
) : TestCaseExtension {
override suspend fun intercept(
testCase: TestCase,
execute: suspend (TestCase) -> TestResult,
): TestResult {
val dispatcher = BukkitMainThreadDispatcher(plugin)
val newContext = coroutineContext + dispatcher
return withContext(newContext) {
execute(testCase)
}
}
}
object KotestRunner {
@OptIn(KotestInternal::class)
@JvmStatic
fun run(plugin: JavaPlugin) {
// Schedule test asynchronously to avoid deadlock.
Bukkit.getScheduler().runTaskAsynchronously(
plugin,
Runnable {
try {
var hasFailures = false
val failureLines = ArrayList(16)
fun throwableFromResult(result: TestResult): Throwable? {
// Avoid depending on Kotest internals: fetch any Throwable via reflection for cross-version tolerance.
val candidateGetters =
listOf(
"getErrorOrNull",
"getCauseOrNull",
"getThrowableOrNull",
"getFailureOrNull",
)
for (getter in candidateGetters) {
val m = result::class.java.methods.firstOrNull { it.name == getter && it.parameterCount == 0 } ?: continue
val t = runCatching { m.invoke(result) }.getOrNull() as? Throwable
if (t != null) return t
}
return null
}
fun formatFailure(
testCase: TestCase,
result: TestResult,
): String {
val specName = testCase.spec::class.qualifiedName ?: testCase.spec::class.java.name
val testName = testCase.displayName
val t = throwableFromResult(result)
if (t == null) return "$specName, $testName"
val message =
t.message
?.lineSequence()
?.firstOrNull()
?.trim()
.orEmpty()
val head = if (message.isNotEmpty()) "${t::class.java.simpleName}: $message" else t::class.java.simpleName
val frame = t.stackTrace.firstOrNull()
val at = if (frame != null) " (${frame.fileName}:${frame.lineNumber})" else ""
return "$specName, $testName -- $head$at"
}
val listener =
object : AbstractTestEngineListener() {
override suspend fun testFinished(
testCase: TestCase,
result: TestResult,
) {
if (result.isFailure || result.isError) {
hasFailures = true
if (failureLines.size < 25) {
failureLines.add(formatFailure(testCase, result))
}
}
}
override suspend fun engineFinished(t: List) {
val success = t.isEmpty() && !hasFailures
TestResultWriter.writeFailureSummary(plugin, failureLines)
TestResultWriter.writeAndShutdown(plugin, success)
}
}
val compositeListener =
CompositeTestEngineListener(
listOf(
EnhancedConsoleTestEngineListener(TermColors()),
listener,
),
)
TestEngineLauncher()
.withListener(compositeListener)
.withProjectConfig(KotestProjectConfig)
.withClasses(
ConfigMigrationIntegrationTest::class,
ModesetRulesIntegrationTest::class,
DisableOffhandIntegrationTest::class,
DisableOffhandReflectionIntegrationTest::class,
InGameTesterIntegrationTest::class,
CopperToolsIntegrationTest::class,
OldPotionEffectsIntegrationTest::class,
InvulnerabilityDamageIntegrationTest::class,
FireAspectOverdamageIntegrationTest::class,
OldCriticalHitsIntegrationTest::class,
OldToolDamageMobIntegrationTest::class,
WeaponDurabilityIntegrationTest::class,
GoldenAppleIntegrationTest::class,
OldArmourDurabilityIntegrationTest::class,
PlayerKnockbackIntegrationTest::class,
AttackCooldownTrackerIntegrationTest::class,
AttackCooldownHeldItemIntegrationTest::class,
PlayerRegenIntegrationTest::class,
FishingRodVelocityIntegrationTest::class,
SwordSweepIntegrationTest::class,
PacketCancellationIntegrationTest::class,
EnderpearlCooldownIntegrationTest::class,
SpigotFunctionChooserIntegrationTest::class,
ChorusFruitIntegrationTest::class,
CustomWeaponDamageIntegrationTest::class,
ToolDamageTooltipIntegrationTest::class,
SwordBlockingIntegrationTest::class,
ConsumableComponentIntegrationTest::class,
PaperSwordBlockingDamageReductionIntegrationTest::class,
AttackRangeIntegrationTest::class,
).launch()
} catch (e: Throwable) {
plugin.logger.severe("Failed to execute Kotest runner: ${e.message}")
TestResultWriter.writeAndShutdown(plugin, false, e)
}
},
)
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/LegacyFakePlayer12.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.mojang.authlib.GameProfile
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.embedded.EmbeddedChannel
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerPreLoginEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.net.InetAddress
import java.util.UUID
internal class LegacyFakePlayer12(
private val plugin: JavaPlugin,
val uuid: UUID,
val name: String
) {
private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')
var entityPlayer: Any? = null
private set
var bukkitPlayer: Player? = null
private set
private fun nmsClass(simpleName: String): Class<*> =
Class.forName("net.minecraft.server.$cbVersion.$simpleName")
private fun craftClass(simpleName: String): Class<*> =
Class.forName("org.bukkit.craftbukkit.$cbVersion.$simpleName")
fun spawn(location: Location) {
plugin.logger.info("Spawn: Starting (legacy $cbVersion)")
val world = location.world ?: throw IllegalArgumentException("Location has no world!")
val craftWorld = craftClass("CraftWorld").cast(world)
val worldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val entityPlayer = createEntityPlayer(minecraftServer, worldServer)
this.entityPlayer = entityPlayer
val bukkitPlayer = entityPlayer.javaClass.getMethod("getBukkitEntity").invoke(entityPlayer) as Player
this.bukkitPlayer = bukkitPlayer
firePreLoginEvents()
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
invokeMethodIfExists(playerList, "a", entityPlayer)
setPositionRotation(entityPlayer, location)
setDataWatcherFlags(entityPlayer)
spawnInWorld(entityPlayer, worldServer)
setGameMode(entityPlayer, worldServer)
setupConnection(entityPlayer, minecraftServer)
addToPlayerChunkMap(worldServer, entityPlayer)
addToPlayerList(playerList, entityPlayer)
updatePlayerListMaps(playerList, entityPlayer)
val joinMessage = "§e$name joined the game"
val joinEvent = PlayerJoinEvent(bukkitPlayer, joinMessage)
Bukkit.getPluginManager().callEvent(joinEvent)
broadcastMessage(joinEvent.joinMessage)
sendSpawnPackets(entityPlayer)
addEntityToWorld(worldServer, entityPlayer)
Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
runCatching { invokeMethod(entityPlayer, "playerTick") }
}, 1L, 1L)
plugin.logger.info("Spawn: completed successfully (legacy)")
}
fun removePlayer() {
val entityPlayer = entityPlayer ?: return
val bukkitPlayer = bukkitPlayer ?: return
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
val quitMessage = "§e$name left the game"
val quitEvent = PlayerQuitEvent(bukkitPlayer, quitMessage)
Bukkit.getPluginManager().callEvent(quitEvent)
val worldServer = getWorldServer(entityPlayer)
val playerChunkMap = getPlayerChunkMap(worldServer)
invokeMethodIfExists(playerChunkMap, "removePlayer", entityPlayer)
invokeMethodIfExists(worldServer, "removeEntity", entityPlayer)
bukkitPlayer.kickPlayer(quitMessage)
removeFromPlayerList(playerList, entityPlayer)
removeFromPlayerMaps(playerList, entityPlayer)
sendRemovePackets(entityPlayer)
invokeMethodIfExists(playerList, "savePlayerFile", entityPlayer)
}
fun startUsingOffhand() {
val entityPlayer = entityPlayer ?: return
val enumHandClass = nmsClass("EnumHand")
val offHand = enumValue(enumHandClass, "OFF_HAND")
val candidateNames = arrayOf("c", "a", "startUsingItem")
for (name in candidateNames) {
val method = findMethod(entityPlayer.javaClass, name, arrayOf(offHand), ignoreMissing = true)
if (method != null) {
method.invoke(entityPlayer, offHand)
return
}
}
val fallback = entityPlayer.javaClass.methods.firstOrNull { method ->
method.parameterTypes.size == 1 && method.parameterTypes[0] == enumHandClass
}
fallback?.invoke(entityPlayer, offHand)
}
fun getConnection(serverPlayer: Any): Any {
val connectionField = findField(serverPlayer.javaClass, "playerConnection")
?: throw NoSuchFieldException("playerConnection not found on ${serverPlayer.javaClass.name}")
return connectionField.get(serverPlayer)
}
private fun createEntityPlayer(minecraftServer: Any, worldServer: Any): Any {
val entityPlayerClass = nmsClass("EntityPlayer")
val playerInteractManagerClass = nmsClass("PlayerInteractManager")
val pimCtor = playerInteractManagerClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)
} ?: playerInteractManagerClass.constructors.first()
val playerInteractManager = pimCtor.newInstance(worldServer)
val gameProfile = GameProfile(uuid, name)
val ctor = entityPlayerClass.constructors.firstOrNull { ctor ->
val params = ctor.parameterTypes
params.size == 4 &&
params[0].isAssignableFrom(minecraftServer.javaClass) &&
params[1].isAssignableFrom(worldServer.javaClass) &&
params[2] == GameProfile::class.java &&
params[3].isAssignableFrom(playerInteractManagerClass)
} ?: entityPlayerClass.constructors.first()
return ctor.newInstance(minecraftServer, worldServer, gameProfile, playerInteractManager)
}
private fun firePreLoginEvents() {
try {
val address = InetAddress.getByName("127.0.0.1")
val asyncPreLogin = AsyncPlayerPreLoginEvent(name, address, uuid)
val preLogin = PlayerPreLoginEvent(name, address, uuid)
Thread { Bukkit.getPluginManager().callEvent(asyncPreLogin) }.start()
Bukkit.getPluginManager().callEvent(preLogin)
} catch (e: Exception) {
plugin.logger.warning("Failed to fire pre-login events: ${e.message}")
}
}
private fun setPositionRotation(entityPlayer: Any, location: Location) {
val method = entityPlayer.javaClass.getMethod(
"setPositionRotation",
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType
)
method.invoke(
entityPlayer,
location.x,
location.y,
location.z,
location.yaw,
location.pitch
)
}
private fun setDataWatcherFlags(entityPlayer: Any) {
runCatching {
val dataWatcher = invokeMethod(entityPlayer, "getDataWatcher")
val dataWatcherRegistryClass = nmsClass("DataWatcherRegistry")
val serializer = dataWatcherRegistryClass.getField("a").get(null)
val dataWatcherObject = invokeMethod(serializer, "a", 13)
val setMethod = dataWatcher.javaClass.methods.firstOrNull { it.name == "set" && it.parameterCount == 2 }
setMethod?.invoke(dataWatcher, dataWatcherObject, 127.toByte())
}
}
private fun spawnInWorld(entityPlayer: Any, worldServer: Any) {
invokeMethodIfExists(entityPlayer, "spawnIn", worldServer)
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer)
if (playerInteractManager != null) {
invokeMethodIfExists(playerInteractManager, "a", worldServer)
}
}
private fun setGameMode(entityPlayer: Any, worldServer: Any) {
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer) ?: return
val enumGamemodeClass = nmsClass("EnumGamemode")
val gameMode = Bukkit.getServer().defaultGameMode
val enumValue = enumValue(enumGamemodeClass, gameMode)
invokeMethodIfExists(playerInteractManager, "b", enumValue)
}
private fun setupConnection(entityPlayer: Any, minecraftServer: Any) {
val networkManagerClass = nmsClass("NetworkManager")
val enumProtocolDirectionClass = nmsClass("EnumProtocolDirection")
val serverbound = enumValue(enumProtocolDirectionClass, "SERVERBOUND")
val networkManager = networkManagerClass
.getConstructor(enumProtocolDirectionClass)
.newInstance(serverbound)
val playerConnectionClass = nmsClass("PlayerConnection")
val pcCtor = playerConnectionClass.constructors.firstOrNull { ctor ->
val params = ctor.parameterTypes
params.size == 3 &&
params[0].isAssignableFrom(minecraftServer.javaClass) &&
params[1].isAssignableFrom(networkManagerClass) &&
params[2].isAssignableFrom(entityPlayer.javaClass)
} ?: playerConnectionClass.constructors.first()
val playerConnection = pcCtor.newInstance(minecraftServer, networkManager, entityPlayer)
setFieldValue(entityPlayer, "playerConnection", playerConnection)
val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())
setFieldValue(networkManager, "channel", channel)
runCatching { channel.close() }
}
private fun addToPlayerChunkMap(worldServer: Any, entityPlayer: Any) {
val playerChunkMap = getPlayerChunkMap(worldServer)
invokeMethodIfExists(playerChunkMap, "addPlayer", entityPlayer)
}
private fun addToPlayerList(playerList: Any, entityPlayer: Any) {
runCatching {
val playersField = findField(playerList.javaClass, "players")
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection
players?.add(entityPlayer)
}
}
private fun updatePlayerListMaps(playerList: Any, entityPlayer: Any) {
runCatching {
val byUuidField = findField(playerList.javaClass, "j")
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap
byUuid?.put(uuid, entityPlayer)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap
byName?.put(name, entityPlayer)
}
}
private fun sendSpawnPackets(entityPlayer: Any) {
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
val addAction = enumValue(actionClass, "ADD_PLAYER")
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
val infoPacket = packetPlayOutPlayerInfo
.getConstructor(actionClass, entityArray.javaClass)
.newInstance(addAction, entityArray)
val namedSpawnPacket = createSingleArgPacket("PacketPlayOutNamedEntitySpawn", entityPlayer)
val craftPlayerClass = craftClass("entity.CraftPlayer")
for (player in Bukkit.getOnlinePlayers()) {
val craftPlayer = craftPlayerClass.cast(player)
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
sendPacket(connection, infoPacket)
if (namedSpawnPacket != null) {
sendPacket(connection, namedSpawnPacket)
}
}
}
private fun sendRemovePackets(entityPlayer: Any) {
val packetDestroy = createEntityDestroyPacket(entityPlayer)
val packetInfo = createPlayerInfoPacket("REMOVE_PLAYER", entityPlayer)
val craftPlayerClass = craftClass("entity.CraftPlayer")
for (player in Bukkit.getOnlinePlayers()) {
val craftPlayer = craftPlayerClass.cast(player)
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
if (packetDestroy != null) sendPacket(connection, packetDestroy)
if (packetInfo != null) sendPacket(connection, packetInfo)
}
}
private fun createPlayerInfoPacket(actionName: String, entityPlayer: Any): Any? {
return runCatching {
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
val action = enumValue(actionClass, actionName)
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
packetPlayOutPlayerInfo
.getConstructor(actionClass, entityArray.javaClass)
.newInstance(action, entityArray)
}.getOrNull()
}
private fun createEntityDestroyPacket(entityPlayer: Any): Any? {
return runCatching {
val packetClass = nmsClass("PacketPlayOutEntityDestroy")
val getIdMethod = entityPlayer.javaClass.getMethod("getId")
val entityId = getIdMethod.invoke(entityPlayer) as Int
val ids = intArrayOf(entityId)
packetClass.getConstructor(IntArray::class.java).newInstance(ids)
}.getOrNull()
}
private fun createSingleArgPacket(className: String, entityPlayer: Any): Any? {
return runCatching {
val packetClass = nmsClass(className)
val ctor = packetClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass)
} ?: packetClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass.superclass)
}
ctor?.newInstance(entityPlayer)
}.getOrNull()
}
private fun addEntityToWorld(worldServer: Any, entityPlayer: Any) {
invokeMethodIfExists(worldServer, "addEntity", entityPlayer)
}
private fun getWorldServer(entityPlayer: Any): Any {
val worldField = findField(entityPlayer.javaClass, "world")
return worldField?.get(entityPlayer) ?: invokeMethod(entityPlayer, "getWorld")
}
private fun getPlayerChunkMap(worldServer: Any): Any {
val getPlayerChunkMap = worldServer.javaClass.methods.firstOrNull { it.name == "getPlayerChunkMap" }
return if (getPlayerChunkMap != null) {
getPlayerChunkMap.invoke(worldServer)
} else {
findField(worldServer.javaClass, "playerChunkMap")?.get(worldServer)
?: throw NoSuchFieldException("playerChunkMap not found on ${worldServer.javaClass.name}")
}
}
private fun removeFromPlayerList(playerList: Any, entityPlayer: Any) {
runCatching {
val playersField = findField(playerList.javaClass, "players")
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection
players?.remove(entityPlayer)
}
}
private fun removeFromPlayerMaps(playerList: Any, entityPlayer: Any) {
runCatching {
val byUuidField = findField(playerList.javaClass, "j")
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap
byUuid?.remove(uuid)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap
byName?.remove(name)
}
}
private fun sendPacket(connection: Any, packet: Any) {
val packetClass = nmsClass("Packet")
val sendMethod = connection.javaClass.getMethod("sendPacket", packetClass)
sendMethod.invoke(connection, packet)
}
private fun broadcastMessage(message: String?) {
if (message.isNullOrEmpty()) return
for (player in Bukkit.getOnlinePlayers()) {
player.sendMessage(message)
}
}
private fun invokeMethod(target: Any, name: String, vararg args: Any?): Any {
val method = findMethod(target.javaClass, name, args)
?: throw NoSuchMethodException("Method '$name' not found on ${target.javaClass.name}")
return method.invoke(target, *args)
}
private fun invokeMethodIfExists(target: Any, name: String, vararg args: Any?) {
val method = findMethod(target.javaClass, name, args, ignoreMissing = true) ?: return
method.invoke(target, *args)
}
private fun findMethod(
clazz: Class<*>,
name: String,
args: Array,
ignoreMissing: Boolean = false
): Method? {
val candidates = (clazz.methods + clazz.declaredMethods).filter { it.name == name }
val method = candidates.firstOrNull { method ->
if (method.parameterTypes.size != args.size) return@firstOrNull false
method.parameterTypes.indices.all { idx ->
val arg = args[idx]
arg == null || method.parameterTypes[idx].isAssignableFrom(arg.javaClass)
}
} ?: candidates.firstOrNull { it.parameterTypes.size == args.size }
if (method == null && !ignoreMissing) {
throw NoSuchMethodException("Method '$name' not found on ${clazz.name}")
}
method?.isAccessible = true
return method
}
private fun findField(clazz: Class<*>, name: String): Field? {
var current: Class<*>? = clazz
while (current != null) {
runCatching {
val field = current.getDeclaredField(name)
field.isAccessible = true
return field
}
current = current.superclass
}
return null
}
private fun setFieldValue(target: Any, name: String, value: Any?) {
val field = findField(target.javaClass, name) ?: return
field.set(target, value)
}
private fun enumValue(enumClass: Class<*>, name: String): Any {
@Suppress("UNCHECKED_CAST")
val enumType = enumClass as Class>
return java.lang.Enum.valueOf(enumType, name)
}
private fun enumValue(enumClass: Class<*>, gameMode: GameMode): Any {
return enumValue(enumClass, gameMode.name)
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/LegacyFakePlayer9.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.mojang.authlib.GameProfile
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.ChannelOutboundHandlerAdapter
import io.netty.channel.embedded.EmbeddedChannel
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
import org.bukkit.event.player.PlayerPreLoginEvent
import org.bukkit.plugin.java.JavaPlugin
import java.net.InetAddress
import java.util.*
/**
* Fake player for 1.9.x (v1_9_R2). Uses PlayerList#a(NetworkManager, EntityPlayer)
* so the server treats it like a real online player (damage/knockback events).
*/
internal class LegacyFakePlayer9(
private val plugin: JavaPlugin,
val uuid: UUID,
val name: String
) {
private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')
var entityPlayer: Any? = null
private set
var bukkitPlayer: Player? = null
private set
private fun nms(simple: String): Class<*> =
Class.forName("net.minecraft.server.$cbVersion.$simple", true, Bukkit.getServer().javaClass.classLoader)
private fun craft(simple: String): Class<*> =
Class.forName("org.bukkit.craftbukkit.$cbVersion.$simple", true, Bukkit.getServer().javaClass.classLoader)
fun spawn(location: Location) {
val world = location.world ?: error("Location has no world")
val craftWorld = craft("CraftWorld").cast(world)
val worldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
val ep = createEntityPlayer(mcServer, worldServer)
entityPlayer = ep
bukkitPlayer = ep.javaClass.getMethod("getBukkitEntity").invoke(ep) as Player
// Ensure NMS alive/dead flags are sane so Bukkit sees the player as valid.
runCatching {
val deadField = ep.javaClass.superclass.getDeclaredField("dead")
deadField.isAccessible = true
deadField.setBoolean(ep, false)
}
runCatching {
val health = ep.javaClass.getMethod("setHealth", Float::class.javaPrimitiveType)
health.invoke(ep, 20.0f)
}
firePreLogin()
setPosition(ep, location)
setGameMode(ep)
setVulnerability(ep, false)
setMetaDefaults(ep)
val nm = setupConnection(ep, mcServer)
// Run the real join pipeline
playerList.javaClass.getMethod("a", nms("NetworkManager"), nms("EntityPlayer"))
.apply { isAccessible = true }
.invoke(playerList, nm, ep)
// Clean up the duplicate UUID warning: ensure we don't re-add if PlayerList already did
runCatching {
val playersField = playerList.javaClass.getDeclaredField("players")
playersField.isAccessible = true
val list = playersField.get(playerList) as MutableList
if (!list.contains(ep)) list.add(ep)
}
// Ensure CraftServer maps see the player (for Bukkit.getPlayer)
updateCraftMaps(craftServer, bukkitPlayer!!)
// Force entity into world lists (safety)
runCatching { worldServer.javaClass.getMethod("addEntity", nms("Entity")).invoke(worldServer, ep) }
// Ensure chunk tracking in case join path skipped it
runCatching {
val pcm = worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
pcm.javaClass.methods.firstOrNull { it.name == "addPlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
pcm.javaClass.methods.firstOrNull { it.name == "movePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
}
// Broadcast spawn packets (ADD_PLAYER + NamedEntitySpawn) to online players
runCatching {
val packetInfoClass = nms("PacketPlayOutPlayerInfo")
val enumInfo = packetInfoClass.declaredClasses.first { it.simpleName.contains("EnumPlayerInfoAction") }
val addPlayer = enumInfo.getField("ADD_PLAYER").get(null)
val epArray = java.lang.reflect.Array.newInstance(nms("EntityPlayer"), 1).apply {
java.lang.reflect.Array.set(this, 0, ep)
}
val infoCtor = packetInfoClass.getConstructor(enumInfo, epArray.javaClass)
val infoPacket = infoCtor.newInstance(addPlayer, epArray)
val spawnPacketClass = nms("PacketPlayOutNamedEntitySpawn")
val spawnCtor = spawnPacketClass.getConstructor(nms("EntityHuman"))
val spawnPacket = spawnCtor.newInstance(ep)
val attrPacketClass = nms("PacketPlayOutUpdateAttributes")
val attrCtor = attrPacketClass.getConstructor(nms("EntityLiving"), java.util.Collection::class.java)
val attrMethod = ep.javaClass.methods.firstOrNull { it.name == "getAttributeInstance" }
val attributes = java.util.ArrayList()
// health and attack damage attributes if available
runCatching {
val generic = Class.forName("org.bukkit.attribute.Attribute")
val healthEnum = generic.getField("GENERIC_MAX_HEALTH").get(null)
val dmgEnum = generic.getField("GENERIC_ATTACK_DAMAGE").get(null)
val attrBase = ep.javaClass.methods.firstOrNull { it.name == "getAttributeInstance" && it.parameterTypes.size == 1 }
val healthInst = attrBase?.invoke(ep, nms("GenericAttributes").getField("MAX_HEALTH").get(null))
val dmgInst = attrBase?.invoke(ep, nms("GenericAttributes").getField("ATTACK_DAMAGE").get(null))
if (healthInst != null) attributes.add(healthInst)
if (dmgInst != null) attributes.add(dmgInst)
}
val attrPacket = runCatching { attrCtor.newInstance(ep, attributes) }.getOrNull()
val heldSlotClass = nms("PacketPlayOutHeldItemSlot")
val heldSlotPacket = runCatching { heldSlotClass.getConstructor(Int::class.javaPrimitiveType).newInstance(0) }.getOrNull()
val windowItemsClass = nms("PacketPlayOutWindowItems")
val inventory = ep.javaClass.getField("inventory").get(ep)
val getContents = inventory.javaClass.methods.firstOrNull { it.name == "getContents" && it.parameterCount == 0 }
val contents = runCatching { getContents?.invoke(inventory) as? Array }.getOrNull()
val windowPacket = runCatching { windowItemsClass.constructors.first().newInstance(0, listOf(*contents ?: emptyArray())) }.getOrNull()
val healthClass = nms("PacketPlayOutUpdateHealth")
val healthPacket = runCatching {
val health = ep.javaClass.getMethod("getHealth").invoke(ep) as Float
val food = ep.javaClass.getMethod("getFoodData").invoke(ep)
val foodLevel = food.javaClass.getMethod("getFoodLevel").invoke(food) as Int
val saturation = food.javaClass.getMethod("getSaturationLevel").invoke(food) as Float
healthClass.getConstructor(Float::class.javaPrimitiveType, Int::class.javaPrimitiveType, Float::class.javaPrimitiveType)
.newInstance(health, foodLevel, saturation)
}.getOrNull()
val metaClass = nms("PacketPlayOutEntityMetadata")
val metaCtor = metaClass.getConstructor(Int::class.javaPrimitiveType, nms("DataWatcher"), Boolean::class.javaPrimitiveType)
val dataWatcher = ep.javaClass.getMethod("getDataWatcher").invoke(ep)
val metaPacket = metaCtor.newInstance(ep.javaClass.getMethod("getId").invoke(ep) as Int, dataWatcher, true)
val animClass = nms("PacketPlayOutAnimation")
val swingPacket = runCatching { animClass.getConstructor(nms("Entity"), Int::class.javaPrimitiveType).newInstance(ep, 0) }.getOrNull()
Bukkit.getOnlinePlayers().forEach { viewer ->
val handle = viewer.javaClass.getMethod("getHandle").invoke(viewer)
val conn = handle.javaClass.getField("playerConnection").get(handle)
val send = conn.javaClass.methods.first { it.name == "sendPacket" && it.parameterTypes.size == 1 }
send.invoke(conn, infoPacket)
send.invoke(conn, spawnPacket)
if (attrPacket != null) send.invoke(conn, attrPacket)
if (heldSlotPacket != null) send.invoke(conn, heldSlotPacket)
if (windowPacket != null) send.invoke(conn, windowPacket)
if (healthPacket != null) send.invoke(conn, healthPacket)
send.invoke(conn, metaPacket)
if (swingPacket != null) send.invoke(conn, swingPacket)
}
}
// Tick task to keep status/effects progressing
Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
runCatching {
// playerTick is "m" in 1.9; fall back to "n" if obf differs
val tick = ep.javaClass.methods.firstOrNull { it.name == "m" && it.parameterCount == 0 }
?: ep.javaClass.methods.firstOrNull { it.name == "n" && it.parameterCount == 0 }
?: ep.javaClass.methods.firstOrNull { it.name == "playerTick" && it.parameterCount == 0 }
tick?.invoke(ep)
// Keep entity alive/valid flags cleared so Bukkit reports the player as valid
runCatching {
val deadField = ep.javaClass.superclass.getDeclaredField("dead")
deadField.isAccessible = true
deadField.setBoolean(ep, false)
}
runCatching {
val craftEntity = Class.forName("org.bukkit.craftbukkit.$cbVersion.entity.CraftEntity")
val validField = craftEntity.getDeclaredField("valid")
validField.isAccessible = true
validField.setBoolean(bukkitPlayer, true)
}
}
// keep chunk tracking fresh
runCatching {
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
val pcm = worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
pcm.javaClass.methods.firstOrNull { it.name == "movePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
}
// Apply queued effects/fire ticks
runCatching {
// Force entity base tick for fire/water checks
ep.javaClass.methods.firstOrNull { it.name == "ae" && it.parameterCount == 0 } // baseTick in 1.9 obf
?.invoke(ep)
}
// Ensure water extinguishes burning for fake players on legacy
runCatching {
val bp = bukkitPlayer
if (bp != null && bp.fireTicks > 0 && bp.location.block.isLiquid) {
bp.fireTicks = 0
}
}
}, 1L, 1L)
}
fun removePlayer() {
val ep = entityPlayer ?: return
val bp = bukkitPlayer ?: return
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
bp.kickPlayer("§e$name left the game")
runCatching {
playerList.javaClass.getMethod("disconnect", nms("EntityPlayer")).invoke(playerList, ep)
}.onFailure {
runCatching { playerList.javaClass.getMethod("remove", nms("EntityPlayer")).invoke(playerList, ep) }
}
runCatching {
val pcm = getPlayerChunkMap(ep)
pcm?.javaClass?.methods?.firstOrNull { it.name == "removePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
}
}
fun getConnection(serverPlayer: Any): Any {
val field = serverPlayer.javaClass.getField("playerConnection")
return field.get(serverPlayer)
}
private fun createEntityPlayer(mcServer: Any, worldServer: Any): Any {
val epClass = nms("EntityPlayer")
val pimClass = nms("PlayerInteractManager")
// PlayerInteractManager(World | WorldServer)
val pimCtor = pimClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)
} ?: pimClass.constructors.first()
val pim = pimCtor.newInstance(worldServer)
val gp = GameProfile(uuid, name)
val ctor = epClass.constructors.firstOrNull { ctor ->
val p = ctor.parameterTypes
p.size == 4 &&
p[0].isAssignableFrom(mcServer.javaClass) &&
p[1].isAssignableFrom(worldServer.javaClass) &&
p[2].isAssignableFrom(GameProfile::class.java) &&
p[3].isAssignableFrom(pimClass)
} ?: epClass.constructors.first()
return ctor.newInstance(mcServer, worldServer, gp, pim)
}
private fun setupConnection(ep: Any, mcServer: Any): Any {
val nmClass = nms("NetworkManager")
val dirClass = nms("EnumProtocolDirection")
val clientbound = dirClass.getField("CLIENTBOUND").get(null)
val nm = nmClass.getConstructor(dirClass).newInstance(clientbound)
// Dummy channel with predictable address
val remote = java.net.InetSocketAddress("127.0.0.1", 25565)
val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())
val pipeline = channel.pipeline()
if (pipeline.get("decoder") == null) {
pipeline.addLast("decoder", ChannelInboundHandlerAdapter())
}
if (pipeline.get("encoder") == null) {
pipeline.addLast("encoder", ChannelOutboundHandlerAdapter())
}
nmClass.getField("channel").set(nm, channel)
runCatching { nmClass.getField("socketAddress").set(nm, remote) }
val pcClass = nms("PlayerConnection")
val pc = pcClass.getConstructor(nms("MinecraftServer"), nmClass, nms("EntityPlayer"))
.newInstance(mcServer, nm, ep)
ep.javaClass.getField("playerConnection").set(ep, pc)
runCatching {
val setListener = nmClass.methods.firstOrNull { it.name == "setPacketListener" && it.parameterCount == 1 }
setListener?.invoke(nm, pc)
}
runCatching {
val enumProtocol = nms("EnumProtocol")
val play = enumProtocol.getField("PLAY").get(null)
nmClass.methods.firstOrNull { it.name == "a" && it.parameterTypes.singleOrNull() == enumProtocol }
?.invoke(nm, play)
}
runCatching {
nmClass.fields.firstOrNull { it.name == "isPending" }?.setBoolean(nm, false)
}
return nm
}
private fun setPosition(ep: Any, loc: Location) {
ep.javaClass.getMethod(
"setPositionRotation",
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType
).invoke(ep, loc.x, loc.y, loc.z, loc.yaw, loc.pitch)
}
private fun setGameMode(ep: Any) {
val gmName = when (Bukkit.getDefaultGameMode()) {
GameMode.CREATIVE -> "CREATIVE"
GameMode.ADVENTURE -> "ADVENTURE"
GameMode.SPECTATOR -> "SPECTATOR"
else -> "SURVIVAL"
}
val enumGMClass = nms("WorldSettings\$EnumGamemode")
val enumGM = enumGMClass.getField(gmName).get(null)
val pim = ep.javaClass.getField("playerInteractManager").get(ep)
// prefer setGameMode / b(EnumGamemode)
val method = pim.javaClass.methods.firstOrNull { it.name in listOf("setGameMode", "b") && it.parameterTypes.size == 1 }
?: pim.javaClass.getMethod("b", enumGMClass)
method.isAccessible = true
method.invoke(pim, enumGM)
}
private fun setVulnerability(ep: Any, invulnerable: Boolean) {
runCatching { ep.javaClass.getField("invulnerableTicks").setInt(ep, if (invulnerable) 20 else 0) }
runCatching { ep.javaClass.getField("noDamageTicks").setInt(ep, if (invulnerable) 20 else 0) }
runCatching {
val abilities = ep.javaClass.getField("abilities").get(ep)
val flags = mapOf(
"isInvulnerable" to invulnerable,
"isFlying" to false,
"mayfly" to false,
"canInstantlyBuild" to false
)
flags.forEach { (k, v) ->
runCatching { abilities.javaClass.getField(k).setBoolean(abilities, v) }
}
ep.javaClass.getMethod("updateAbilities").invoke(ep)
}
}
private fun setMetaDefaults(ep: Any) {
runCatching {
val dw = ep.javaClass.getMethod("getDataWatcher").invoke(ep)
val serializerRegistry = nms("DataWatcherRegistry")
val byteSerializer = serializerRegistry.getField("a").get(null)
val floatSerializer = serializerRegistry.getField("c").get(null)
val dwoClass = nms("DataWatcherObject")
val dwoCtor = dwoClass.getConstructor(Int::class.javaPrimitiveType, nms("DataWatcherSerializer"))
val set = dw.javaClass.methods.first { it.name == "set" && it.parameterTypes.size == 2 }
// flag byte (index 0) -> 0
val flag0 = dwoCtor.newInstance(0, byteSerializer)
set.invoke(dw, flag0, 0.toByte())
// health (index 6) -> 20f
val healthObj = dwoCtor.newInstance(6, floatSerializer)
set.invoke(dw, healthObj, 20.0f)
}
}
private fun firePreLogin() {
val addr = InetAddress.getLoopbackAddress()
val async = AsyncPlayerPreLoginEvent(name, addr, uuid)
val sync = PlayerPreLoginEvent(name, addr, uuid)
Thread { Bukkit.getPluginManager().callEvent(async) }.start()
Bukkit.getPluginManager().callEvent(sync)
}
private fun getChunkProvider(ep: Any): Any? = runCatching {
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
worldServer.javaClass.getMethod("getChunkProviderServer").invoke(worldServer)
}.getOrNull()
private fun getPlayerChunkMap(ep: Any): Any? = runCatching {
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
}.getOrNull()
private fun updateCraftMaps(craftServer: Any, player: Player) {
runCatching {
val playersField = craftServer.javaClass.getDeclaredField("players")
playersField.isAccessible = true
val map = playersField.get(craftServer) as MutableMap
map[player.name.lowercase(Locale.getDefault())] = player
}
runCatching {
val uuidField = craftServer.javaClass.getDeclaredField("playersByUUID")
uuidField.isAccessible = true
val map = uuidField.get(craftServer) as MutableMap
map[player.uniqueId] = player
}
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ModesetRulesIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.assertions.throwables.shouldThrow
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.configuration.ConfigurationSection
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
import java.util.Locale
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ModesetRulesIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
val internalModules = setOf(
"modeset-listener",
"attack-cooldown-tracker",
"entity-damage-listener"
)
val optionalModules = setOf(
"disable-attack-sounds",
"disable-sword-sweep-particles"
)
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun setModeset(player: Player, modeset: String) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
setPlayerData(player.uniqueId, playerData)
}
fun snapshotSection(path: String): Any? {
val section = ocm.config.getConfigurationSection(path)
return section?.getValues(false) ?: ocm.config.get(path)
}
fun restoreSection(path: String, value: Any?) {
ocm.config.set(path, null)
when (value) {
null -> Unit
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
ocm.config.createSection(path, value as Map)
}
else -> ocm.config.set(path, value)
}
}
fun applyConfig(
always: List,
disabled: List,
modesets: Map>,
worldModesets: List
) {
ocm.config.set("always_enabled_modules", always)
ocm.config.set("disabled_modules", disabled)
ocm.config.set("modesets", null)
ocm.config.createSection("modesets", modesets)
ocm.config.set("worlds.world", worldModesets)
ocm.saveConfig()
Config.reload()
}
fun completeAlways(
always: List,
disabled: List,
modesets: Map>
): List {
val assigned = HashSet()
always.forEach { assigned.add(it.lowercase(Locale.ROOT)) }
disabled.forEach { assigned.add(it.lowercase(Locale.ROOT)) }
modesets.values.flatten().forEach { assigned.add(it.lowercase(Locale.ROOT)) }
val filled = LinkedHashSet()
filled.addAll(always)
ModuleLoader.getModules()
.map { it.configName.lowercase(Locale.ROOT) }
.sorted()
.filterNot { assigned.contains(it) }
.filterNot { internalModules.contains(it) }
.forEach { filled.add(it) }
optionalModules
.filterNot { assigned.contains(it) }
.forEach { filled.add(it) }
return filled.toList()
}
suspend fun withConfig(block: suspend () -> Unit) {
val originalAlways = ocm.config.get("always_enabled_modules")
val originalDisabled = ocm.config.get("disabled_modules")
val originalModesets = snapshotSection("modesets")
val originalWorlds = snapshotSection("worlds")
try {
block()
} finally {
runSync {
ocm.config.set("always_enabled_modules", originalAlways)
ocm.config.set("disabled_modules", originalDisabled)
restoreSection("modesets", originalModesets)
restoreSection("worlds", originalWorlds)
ocm.saveConfig()
Config.reload()
}
}
}
beforeSpec {
runSync {
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
}
}
afterSpec {
runSync {
fakePlayer.removePlayer()
}
}
test("always-enabled modules apply regardless of modeset") {
withConfig {
runSync {
applyConfig(
always = completeAlways(
always = listOf("disable-offhand"),
disabled = emptyList(),
modesets = mapOf(
"old" to listOf("old-golden-apples"),
"new" to listOf("old-potion-effects")
)
),
disabled = emptyList(),
modesets = mapOf(
"old" to listOf("old-golden-apples"),
"new" to listOf("old-potion-effects")
),
worldModesets = listOf("old", "new")
)
setModeset(player, "old")
module.isEnabled(player).shouldBeTrue()
setModeset(player, "new")
module.isEnabled(player).shouldBeTrue()
}
}
}
test("disabled modules never apply") {
withConfig {
runSync {
applyConfig(
always = completeAlways(
always = emptyList(),
disabled = listOf("disable-offhand"),
modesets = mapOf(
"old" to listOf("old-golden-apples"),
"new" to listOf("old-potion-effects")
)
),
disabled = listOf("disable-offhand"),
modesets = mapOf(
"old" to listOf("old-golden-apples"),
"new" to listOf("old-potion-effects")
),
worldModesets = listOf("old", "new")
)
setModeset(player, "old")
module.isEnabled(player).shouldBeFalse()
setModeset(player, "new")
module.isEnabled(player).shouldBeFalse()
}
}
}
test("modeset membership controls module activation") {
withConfig {
runSync {
applyConfig(
always = completeAlways(
always = emptyList(),
disabled = emptyList(),
modesets = mapOf(
"old" to listOf("disable-offhand"),
"new" to listOf("old-potion-effects")
)
),
disabled = emptyList(),
modesets = mapOf(
"old" to listOf("disable-offhand"),
"new" to listOf("old-potion-effects")
),
worldModesets = listOf("old", "new")
)
setModeset(player, "old")
module.isEnabled(player).shouldBeTrue()
setModeset(player, "new")
module.isEnabled(player).shouldBeFalse()
}
}
}
test("modules in disabled and another list fail reload") {
withConfig {
runSync {
shouldThrow {
applyConfig(
always = completeAlways(
always = listOf("disable-offhand"),
disabled = listOf("disable-offhand"),
modesets = mapOf(
"old" to listOf("old-potion-effects"),
"new" to listOf("old-golden-apples")
)
),
disabled = listOf("disable-offhand"),
modesets = mapOf(
"old" to listOf("old-potion-effects"),
"new" to listOf("old-golden-apples")
),
worldModesets = listOf("old", "new")
)
}
}
}
}
test("modules missing from all lists fail reload") {
withConfig {
runSync {
val moduleNames = (ModuleLoader.getModules()
.map { it.configName }
.filterNot { internalModules.contains(it) } + optionalModules)
.distinct()
.sorted()
val missing = moduleNames.firstOrNull() ?: error("No modules registered")
val always = moduleNames.filterNot { it == missing }
shouldThrow {
applyConfig(
always = always,
disabled = emptyList(),
modesets = mapOf("old" to emptyList()),
worldModesets = listOf("old")
)
}
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OCMTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import org.bukkit.inventory.ItemStack
class OCMTest(
val weapon: ItemStack,
val armour: Array,
val attackDelay: Long,
val message: String,
val preparations: Runnable
)
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OCMTestMain.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.lang.reflect.InvocationTargetException
import java.util.logging.Level
class OCMTestMain : JavaPlugin() {
override fun onEnable() {
logger.info("Enabled OCMTest plugin")
// Initialise player data storage
val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as OCMMain
PlayerStorage.initialise(ocm)
val javaVersion = detectJavaVersion()
logger.info("Detected Java $javaVersion for integration tests")
System.setProperty("kotest.framework.classpath.scanning.autoscan.disable", "true")
runKotest()
}
private fun runKotest() {
try {
val runnerClass = Class.forName("kernitus.plugin.OldCombatMechanics.KotestRunner")
val runMethod = runnerClass.getMethod("run", JavaPlugin::class.java)
runMethod.invoke(null, this)
} catch (e: InvocationTargetException) {
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e.targetException ?: e)
TestResultWriter.writeAndShutdown(this, false)
} catch (e: Throwable) {
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e)
TestResultWriter.writeAndShutdown(this, false)
}
}
private fun detectJavaVersion(): Int {
val version = System.getProperty("java.specification.version") ?: return 0
return if (version.startsWith("1.")) {
version.substringAfter("1.").toIntOrNull() ?: 0
} else {
version.toIntOrNull() ?: 0
}
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldArmourDurabilityIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleOldArmourDurability
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerItemDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class OldArmourDurabilityIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleOldArmourDurability not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val reduction = ocm.config.getInt("old-armour-durability.reduction")
try {
block()
} finally {
ocm.config.set("old-armour-durability.reduction", reduction)
module.reload()
ModuleLoader.toggleModules()
}
}
fun setModeset(modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
fun createItemDamageEvent(item: ItemStack, damage: Int): PlayerItemDamageEvent {
val ctor = PlayerItemDamageEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 4 &&
Player::class.java.isAssignableFrom(params[0]) &&
ItemStack::class.java.isAssignableFrom(params[1]) &&
params[2] == Int::class.javaPrimitiveType &&
params[3] == Int::class.javaPrimitiveType
}
return if (ctor != null) {
ctor.newInstance(player, item, damage, damage) as PlayerItemDamageEvent
} else {
PlayerItemDamageEvent(player, item, damage)
}
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val location = Location(world, 0.0, 100.0, 0.0)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
player.isOp = true
setModeset("old")
}
}
afterSpec {
runSync {
fakePlayer.removePlayer()
}
}
beforeTest {
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
setModeset("old")
module.reload()
}
}
context("Armour durability reduction") {
test("worn armour takes reduced durability") {
withConfig {
ocm.config.set("old-armour-durability.reduction", 2)
module.reload()
val helmet = ItemStack(Material.DIAMOND_HELMET)
player.inventory.helmet = helmet
val event = createItemDamageEvent(helmet, 5)
Bukkit.getPluginManager().callEvent(event)
event.damage shouldBe 2
}
}
test("non-armour items are ignored") {
withConfig {
ocm.config.set("old-armour-durability.reduction", 2)
module.reload()
val sword = ItemStack(Material.DIAMOND_SWORD)
player.inventory.setItemInMainHand(sword)
val event = createItemDamageEvent(sword, 5)
Bukkit.getPluginManager().callEvent(event)
event.damage shouldBe 5
}
}
test("elytra is ignored") {
withConfig {
ocm.config.set("old-armour-durability.reduction", 2)
module.reload()
val elytra = ItemStack(Material.ELYTRA)
player.inventory.chestplate = elytra
val event = createItemDamageEvent(elytra, 5)
Bukkit.getPluginManager().callEvent(event)
event.damage shouldBe 5
}
}
}
context("Explosion handling") {
test("explosion damage bypasses durability reduction") {
withConfig {
ocm.config.set("old-armour-durability.reduction", 2)
module.reload()
val helmet = ItemStack(Material.DIAMOND_HELMET)
player.inventory.helmet = helmet
val explosion = EntityDamageEvent(player, EntityDamageEvent.DamageCause.BLOCK_EXPLOSION, 6.0)
Bukkit.getPluginManager().callEvent(explosion)
val event = createItemDamageEvent(helmet, 5)
Bukkit.getPluginManager().callEvent(event)
event.damage shouldBe 5
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldCriticalHitsIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XMaterial
import io.kotest.common.ExperimentalKotest
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleOldCriticalHits
import kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.attribute.AttributeModifier
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.util.Vector
import java.util.concurrent.Callable
import java.util.UUID
import kotlin.math.abs
@OptIn(ExperimentalKotest::class)
class OldCriticalHitsIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val criticalModule = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleOldCriticalHits not registered")
val toolDamageModule = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleOldToolDamage not registered")
lateinit var attacker: Player
lateinit var fakeAttacker: FakePlayer
extensions(MainThreadDispatcherExtension(testPlugin))
val isLegacy = !Reflector.versionIsNewerOrEqualTo(1, 13, 0)
val legacySpeedModifierId = UUID.fromString("c1f6010f-4d2e-4b2e-9a2f-3f0d0f1b2e3c")
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun setOnGround(player: Player, onGround: Boolean) {
val handle = player.javaClass.getMethod("getHandle").invoke(player)
val field = generateSequence(handle.javaClass) { it.superclass }
.mapNotNull { klass ->
runCatching { klass.getDeclaredField("onGround") }.getOrNull()
}
.firstOrNull()
field?.let {
it.isAccessible = true
it.setBoolean(handle, onGround)
return
}
val setOnGroundMethod = generateSequence(handle.javaClass) { it.superclass }
.mapNotNull { klass ->
runCatching { klass.getDeclaredMethod("setOnGround", Boolean::class.javaPrimitiveType) }.getOrNull()
}
.firstOrNull()
setOnGroundMethod?.let {
it.isAccessible = true
it.invoke(handle, onGround)
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(player: Player, item: ItemStack) {
if (isLegacy) {
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()
val speedAttribute = attackSpeedAttribute?.let { player.getAttribute(it) }
speedAttribute
?.modifiers
?.filter { it.uniqueId == legacySpeedModifierId }
?.forEach { speedAttribute.removeModifier(it) }
val speedModifier = createAttributeModifier(
name = "ocm-legacy-speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND,
uuid = legacySpeedModifierId
)
speedAttribute?.addModifier(speedModifier)
return
}
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
val expectedAmounts = modifiers
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }
.map { it.amount }
val knownWeaponAmounts = NewWeaponDamage.values()
.map { it.damage.toDouble() - 1.0 }
.filter { it > 0.0 }
.toSet()
fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001
val existingModifiers = attackAttribute.modifiers.toList()
existingModifiers
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }
.filter { modifier ->
knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&
expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }
}
.forEach { attackAttribute.removeModifier(it) }
modifiers.forEach { modifier ->
val alreadyApplied = attackAttribute.modifiers.any {
it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)
}
if (!alreadyApplied) {
attackAttribute.addModifier(modifier)
}
}
}
fun equip(player: Player, item: ItemStack) {
if (isLegacy) {
// On legacy versions, avoid mutating item meta; directly adjust player attributes instead.
val meta = item.itemMeta
if (meta != null) {
runCatching {
XAttribute.ATTACK_DAMAGE.get()?.let { meta.removeAttributeModifier(it) }
XAttribute.ATTACK_SPEED.get()?.let { meta.removeAttributeModifier(it) }
}
runCatching { meta.removeAttributeModifier(EquipmentSlot.HAND) }
runCatching { meta.removeAttributeModifier(EquipmentSlot.OFF_HAND) }
item.itemMeta = meta
}
val useDamageAttribute = !Reflector.versionIsNewerOrEqualTo(1, 12, 0)
if (useDamageAttribute) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
val damageAttribute = attackDamageAttribute?.let { player.getAttribute(it) }
val configuredDamage = WeaponDamages.getDamage(item.type).toDouble().takeIf { it > 0 }
?: (NewWeaponDamage.getDamageOrNull(item.type) ?: 1.0f).toDouble()
damageAttribute?.baseValue = configuredDamage
}
player.inventory.setItemInMainHand(item)
applyAttackDamageModifiers(player, item)
player.updateInventory()
return
}
prepareWeapon(item)
player.inventory.setItemInMainHand(item)
applyAttackDamageModifiers(player, item)
player.updateInventory()
}
fun spawnVictim(location: Location): LivingEntity {
val world = location.world ?: error("World missing for victim spawn")
return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 0
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
suspend fun hitAndCaptureDamage(
weapon: ItemStack,
critical: Boolean
): Double {
val events = mutableListOf()
val ocmEvents = mutableListOf()
lateinit var victim: LivingEntity
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == attacker.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
testPlugin.logger.info(
"Critical hit debug: weapon=${attacker.inventory.itemInMainHand.type} " +
"critical=$critical sprinting=${attacker.isSprinting} " +
"fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround} " +
"damage=${event.damage} finalDamage=${event.finalDamage}"
)
}
}
@EventHandler
fun onOcm(event: OCMEntityDamageByEntityEvent) {
if (event.damager.uniqueId == attacker.uniqueId &&
event.damagee.uniqueId == victim.uniqueId
) {
ocmEvents.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val victimLocation = Location(world, 1.2, 100.0, 0.0)
victim = spawnVictim(victimLocation)
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
equip(attacker, weapon)
}
delayTicks(1)
if (isLegacy) {
// Vanilla 1.12 applies attack cooldown scaling before the Bukkit damage event fires.
// Give the fake player a short warmup so the baseline (non-critical) hit is not under-scaled.
delayTicks(6)
}
val base = Location(attacker.world, 0.0, 100.0, 0.0)
runSync {
attacker.teleport(base)
attacker.velocity = Vector(0.0, 0.0, 0.0)
attacker.isSprinting = false
attacker.fallDistance = 0f
attacker.updateInventory()
}
if (critical) {
// Give the server one tick to recognise the falling state, then re-apply immediately before the swing
// so it does not get cleared by ticking (varies by version / fake player internals).
runSync {
attacker.isSprinting = true
attacker.teleport(attacker.location.add(0.0, 1.0, 0.0))
attacker.velocity = Vector(0.0, -0.1, 0.0)
attacker.fallDistance = 2f
setOnGround(attacker, false)
}
delayTicks(1)
runSync {
attacker.isSprinting = true
attacker.velocity = Vector(0.0, -0.1, 0.0)
attacker.fallDistance = 2f
setOnGround(attacker, false)
if (!DamageUtils.isCriticalHit1_8(attacker)) {
attacker.fallDistance = 3f
setOnGround(attacker, false)
}
val loc = attacker.location
val dir = victim.location.toVector().subtract(loc.toVector()).normalize()
loc.direction = dir
attacker.teleport(loc)
testPlugin.logger.info(
"Critical pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}"
)
attacker.updateInventory()
attackCompat(attacker, victim)
}
} else {
runSync {
attacker.isSprinting = false
attacker.fallDistance = 0f
attacker.velocity = Vector(0.0, 0.0, 0.0)
val loc = attacker.location
val dir = victim.location.toVector().subtract(loc.toVector()).normalize()
loc.direction = dir
attacker.teleport(loc)
testPlugin.logger.info(
"Normal pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}"
)
attacker.updateInventory()
attackCompat(attacker, victim)
}
}
delayTicks(4)
events.firstOrNull()?.damage?.let { return it }
if (critical) {
val ocmEvent = ocmEvents.lastOrNull()
if (ocmEvent != null && !ocmEvent.was1_8Crit()) {
testPlugin.logger.info(
"Critical path but was1_8Crit=false; fallDistance=${attacker.fallDistance} " +
"onGround=${attacker.isOnGround}"
)
}
}
if (isLegacy) {
// As a last resort on legacy, drive damage via Bukkit API to ensure EDBE fires.
runSync {
val base = WeaponDamages.getDamage(weapon.type).takeIf { it > 0 } ?: 1.0
victim.damage(base, attacker)
}
delayTicks(4)
events.firstOrNull()?.damage?.let { return it }
val healthDelta = (victim.maxHealth - victim.health).toDouble()
if (healthDelta > 0.0) return healthDelta
}
error("Expected a damage event for critical=$critical")
} finally {
HandlerList.unregisterAll(listener)
runSync {
victim.remove()
}
}
}
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val critEnabled = ocm.config.getBoolean("old-critical-hits.enabled")
val critMultiplier = ocm.config.getDouble("old-critical-hits.multiplier")
val critAllowSprinting = ocm.config.getBoolean("old-critical-hits.allow-sprinting")
val damagesSection = ocm.config.getConfigurationSection("old-tool-damage.damages")
val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()
fun reloadDamageModules() {
WeaponDamages.initialise(ocm)
criticalModule.reload()
toolDamageModule.reload()
ModuleLoader.toggleModules()
}
try {
block()
} finally {
ocm.config.set("old-critical-hits.enabled", critEnabled)
ocm.config.set("old-critical-hits.multiplier", critMultiplier)
ocm.config.set("old-critical-hits.allow-sprinting", critAllowSprinting)
damagesSnapshot.forEach { (key, value) ->
ocm.config.set("old-tool-damage.damages.$key", value)
}
reloadDamageModules()
}
}
beforeSpec {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val location = Location(world, 0.0, 100.0, 0.0)
fakeAttacker = FakePlayer(testPlugin)
fakeAttacker.spawn(location)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
attacker.gameMode = GameMode.SURVIVAL
attacker.isInvulnerable = false
attacker.inventory.clear()
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
attacker.isOp = true
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(attacker.uniqueId)
playerData.setModesetForWorld(attacker.world.uid, "old")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(attacker.uniqueId, playerData)
}
}
afterSpec {
runSync {
fakeAttacker.removePlayer()
}
}
test("critical hit multiplier applies to customised tool damage") {
withConfig {
ocm.config.set("old-critical-hits.enabled", true)
ocm.config.set("old-critical-hits.multiplier", 1.5)
ocm.config.set("old-critical-hits.allow-sprinting", true)
ocm.config.set("old-tool-damage.damages.STONE_SWORD", 10)
WeaponDamages.initialise(ocm)
criticalModule.reload()
toolDamageModule.reload()
ModuleLoader.toggleModules()
val stoneSword = XMaterial.STONE_SWORD.parseItem()
?: error("STONE_SWORD material not available")
val normalDamage = hitAndCaptureDamage(stoneSword, critical = false)
val criticalDamage = hitAndCaptureDamage(stoneSword, critical = true)
withClue("normal=$normalDamage critical=$criticalDamage") {
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
}
}
}
test("critical hit multiplier applies when tool damage matches vanilla values") {
withConfig {
ocm.config.set("old-critical-hits.enabled", true)
ocm.config.set("old-critical-hits.multiplier", 1.5)
ocm.config.set("old-critical-hits.allow-sprinting", true)
ocm.config.set("old-tool-damage.damages.IRON_SWORD", 6)
WeaponDamages.initialise(ocm)
criticalModule.reload()
toolDamageModule.reload()
ModuleLoader.toggleModules()
val ironSword = XMaterial.IRON_SWORD.parseItem()
?: error("IRON_SWORD material not available")
val normalDamage = hitAndCaptureDamage(ironSword, critical = false)
val criticalDamage = hitAndCaptureDamage(ironSword, critical = true)
withClue("normal=$normalDamage critical=$criticalDamage") {
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
}
}
}
test("critical hit multiplier applies to config damage for iron axe") {
withConfig {
ocm.config.set("old-critical-hits.enabled", true)
ocm.config.set("old-critical-hits.multiplier", 1.5)
ocm.config.set("old-critical-hits.allow-sprinting", true)
ocm.config.set("old-tool-damage.damages.IRON_AXE", 6)
WeaponDamages.initialise(ocm)
criticalModule.reload()
toolDamageModule.reload()
ModuleLoader.toggleModules()
val ironAxe = XMaterial.IRON_AXE.parseItem()
?: error("IRON_AXE material not available")
val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)
val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)
testPlugin.logger.info("Crit debug (cfg=6): normal=$normalDamage critical=$criticalDamage")
withClue("normal=$normalDamage critical=$criticalDamage") {
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
}
}
}
test("critical hit multiplies configured iron axe damage") {
withConfig {
ocm.config.set("old-critical-hits.enabled", true)
ocm.config.set("old-critical-hits.multiplier", 1.25)
ocm.config.set("old-critical-hits.allow-sprinting", true)
ocm.config.set("old-tool-damage.damages.IRON_AXE", 4.5)
WeaponDamages.initialise(ocm)
criticalModule.reload()
toolDamageModule.reload()
ModuleLoader.toggleModules()
val ironAxe = XMaterial.IRON_AXE.parseItem()
?: error("IRON_AXE material not available")
val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)
val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)
testPlugin.logger.info("Crit debug (cfg=4.5): normal=$normalDamage critical=$criticalDamage")
withClue("normal=$normalDamage critical=$criticalDamage") {
normalDamage shouldBe (4.5 plusOrMinus 0.05)
criticalDamage shouldBe (5.625 plusOrMinus 0.05)
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldPotionEffectsIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kernitus.plugin.OldCombatMechanics.module.ModuleOldPotionEffects
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent
import com.cryptomorin.xseries.XAttribute
import com.cryptomorin.xseries.XMaterial
import com.cryptomorin.xseries.XPotion
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.AttributeModifier
import org.bukkit.block.BlockFace
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.block.Action
import org.bukkit.event.block.BlockDispenseEvent
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.EventPriority
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerItemConsumeEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.PotionMeta
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionData
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.potion.PotionType
import org.bukkit.util.Vector
import kotlinx.coroutines.delay
import java.util.concurrent.Callable
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
@OptIn(ExperimentalKotest::class)
class OldPotionEffectsIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleOldPotionEffects not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
val excludedPotionTypes = setOf(
"AWKWARD",
"MUNDANE",
"THICK",
"WATER",
"HARMING",
"STRONG_HARMING",
"HEALING",
"STRONG_HEALING",
"INSTANT_DAMAGE",
"INSTANT_HEAL",
"INSTANT_HEALTH",
"UNCRAFTABLE"
)
data class PotionCase(
val key: String,
val baseName: String,
val isStrong: Boolean,
val isExtended: Boolean,
val potion: XPotion,
val drinkableTicks: Int,
val splashTicks: Int
)
data class PotionBaseSnapshot(
val baseType: PotionType?,
val isUpgraded: Boolean,
val isExtended: Boolean
)
data class ParsedPotionKey(
val baseName: String,
val isStrong: Boolean,
val isExtended: Boolean,
val debugName: String
)
fun debugName(baseName: String, isStrong: Boolean, isExtended: Boolean): String {
return when {
isStrong -> "STRONG_$baseName"
isExtended -> "LONG_$baseName"
else -> baseName
}
}
suspend fun waitForAttackReady(attacker: Player) {
val cooldownMethod = attacker.javaClass.methods.firstOrNull { method ->
method.name == "getAttackCooldown" && method.parameterTypes.isEmpty()
}
if (cooldownMethod == null) {
delay(700)
return
}
repeat(40) {
val value = (cooldownMethod.invoke(attacker) as? Float) ?: 1.0f
if (value >= 0.99f) return
delay(50)
}
}
fun resolveBasePotionType(baseName: String, potion: XPotion): PotionType? {
return runCatching { PotionType.valueOf(baseName) }.getOrNull()
?: potion.potionType
}
fun parsePotionKey(key: String): ParsedPotionKey {
var name = key.uppercase()
var isStrong = false
var isExtended = false
if (name.startsWith("STRONG_")) {
isStrong = true
name = name.removePrefix("STRONG_")
} else if (name.startsWith("LONG_")) {
isExtended = true
name = name.removePrefix("LONG_")
}
val debugName = debugName(name, isStrong, isExtended)
return ParsedPotionKey(name, isStrong, isExtended, debugName)
}
val hasBasePotionType = runCatching { PotionMeta::class.java.getMethod("getBasePotionType") }.isSuccess
fun potionSupports(baseName: String, isStrong: Boolean, isExtended: Boolean, potion: XPotion): Boolean {
val potionType = resolveBasePotionType(baseName, potion) ?: return false
return if (hasBasePotionType) {
val resolvedName = when {
isStrong -> "STRONG_${potionType.name}"
isExtended -> "LONG_${potionType.name}"
else -> potionType.name
}
runCatching { PotionType.valueOf(resolvedName) }.isSuccess
} else {
runCatching { PotionData(potionType, isExtended, isStrong) }.isSuccess
}
}
fun loadPotionCases(): List {
val drinkable = ocm.config.getConfigurationSection("old-potion-effects.potion-durations.drinkable")
?: return emptyList()
val splash = ocm.config.getConfigurationSection("old-potion-effects.potion-durations.splash")
?: return emptyList()
return drinkable.getKeys(false).mapNotNull { key ->
if (!splash.isInt(key)) return@mapNotNull null
val parsed = parsePotionKey(key)
val potion = XPotion.of(parsed.baseName).orElse(null) ?: return@mapNotNull null
if (excludedPotionTypes.contains(parsed.debugName)) return@mapNotNull null
if (!potionSupports(parsed.baseName, parsed.isStrong, parsed.isExtended, potion)) return@mapNotNull null
PotionCase(
key = key,
baseName = parsed.baseName,
isStrong = parsed.isStrong,
isExtended = parsed.isExtended,
potion = potion,
drinkableTicks = drinkable.getInt(key) * 20,
splashTicks = splash.getInt(key) * 20
)
}
}
fun createPotionItem(material: Material, potionCase: PotionCase): ItemStack {
val item = ItemStack(material)
val meta = item.itemMeta as PotionMeta
val baseType = resolveBasePotionType(potionCase.baseName, potionCase.potion) ?: return item
if (hasBasePotionType) {
val resolvedName = when {
potionCase.isStrong -> "STRONG_${baseType.name}"
potionCase.isExtended -> "LONG_${baseType.name}"
else -> baseType.name
}
val potionType = runCatching { PotionType.valueOf(resolvedName) }.getOrElse { baseType }
meta.basePotionType = potionType
} else {
meta.basePotionData = PotionData(
baseType,
potionCase.isExtended,
potionCase.isStrong
)
}
item.itemMeta = meta
return item
}
fun snapshotBase(meta: PotionMeta): PotionBaseSnapshot {
return try {
PotionBaseSnapshot(meta.basePotionType, false, false)
} catch (e: NoSuchMethodError) {
val baseData = meta.basePotionData
if (baseData == null) {
PotionBaseSnapshot(null, false, false)
} else {
PotionBaseSnapshot(baseData.type, baseData.isUpgraded, baseData.isExtended)
}
}
}
fun expectedEffectTypes(potionType: PotionType): List {
return try {
potionType.potionEffects.map { it.type }
} catch (e: NoSuchMethodError) {
listOfNotNull(potionType.effectType)
}
}
fun expectedAmplifier(baseName: String, isStrong: Boolean): Int {
return if (isStrong) 1 else 0
}
fun assertAdjusted(item: ItemStack, baseName: String, isStrong: Boolean, potion: XPotion, expectedTicks: Int) {
val meta = item.itemMeta as PotionMeta
val potionType = resolveBasePotionType(baseName, potion) ?: error("Potion type missing for $baseName")
val expectedTypes = expectedEffectTypes(potionType)
val expectedAmp = expectedAmplifier(baseName, isStrong)
meta.customEffects.shouldHaveSize(expectedTypes.size)
expectedTypes.forEach { effectType ->
val effect = meta.customEffects.firstOrNull { it.type == effectType }
effect.shouldNotBe(null)
effect!!.duration.shouldBeExactly(expectedTicks)
if (baseName == "WEAKNESS") {
effect.amplifier.shouldBeLessThanOrEqual(0)
} else {
effect.amplifier.shouldBeExactly(expectedAmp)
}
}
val baseSnapshot = snapshotBase(meta)
baseSnapshot.baseType.shouldBe(PotionType.WATER)
baseSnapshot.isUpgraded.shouldBeFalse()
baseSnapshot.isExtended.shouldBeFalse()
}
fun assertUnchanged(item: ItemStack, originalBase: PotionBaseSnapshot) {
val meta = item.itemMeta as PotionMeta
meta.customEffects.shouldHaveSize(0)
val newBase = snapshotBase(meta)
newBase.baseType.shouldBe(originalBase.baseType)
newBase.isUpgraded.shouldBe(originalBase.isUpgraded)
newBase.isExtended.shouldBe(originalBase.isExtended)
}
fun callConsume(item: ItemStack): ItemStack {
val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 3 &&
Player::class.java.isAssignableFrom(params[0]) &&
ItemStack::class.java.isAssignableFrom(params[1]) &&
EquipmentSlot::class.java.isAssignableFrom(params[2])
}
val event = if (ctor != null) {
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
} else {
PlayerItemConsumeEvent(player, item)
}
Bukkit.getPluginManager().callEvent(event)
return event.item
}
fun callThrow(item: ItemStack): ItemStack {
val block = player.location.block
val face = BlockFace.SELF
val ctor = PlayerInteractEvent::class.java.constructors.firstOrNull { constructor ->
val params = constructor.parameterTypes
params.size == 6 &&
Player::class.java.isAssignableFrom(params[0]) &&
params[1] == Action::class.java &&
ItemStack::class.java.isAssignableFrom(params[2]) &&
params[3].name.endsWith("Block") &&
params[4].name.endsWith("BlockFace") &&
params[5] == EquipmentSlot::class.java
}
val event = if (ctor != null) {
ctor.newInstance(player, Action.RIGHT_CLICK_AIR, item, block, face, EquipmentSlot.HAND) as PlayerInteractEvent
} else {
PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, item, block, face)
}
Bukkit.getPluginManager().callEvent(event)
return event.item ?: item
}
fun callDispense(item: ItemStack): ItemStack {
val block = player.world.getBlockAt(player.location.blockX, player.location.blockY, player.location.blockZ)
val event = BlockDispenseEvent(block, item, Vector(0, 0, 0))
Bukkit.getPluginManager().callEvent(event)
return event.item
}
fun assertAdjustedOrUnchanged(
adjusted: ItemStack,
potionCase: PotionCase,
originalBase: PotionBaseSnapshot,
splash: Boolean
) {
val expectedTicks = if (splash) potionCase.splashTicks else potionCase.drinkableTicks
if (excludedPotionTypes.contains(debugName(potionCase.baseName, potionCase.isStrong, potionCase.isExtended))) {
assertUnchanged(adjusted, originalBase)
} else {
assertAdjusted(adjusted, potionCase.baseName, potionCase.isStrong, potionCase.potion, expectedTicks)
}
}
fun findSamplePotionCase(): PotionCase {
return loadPotionCases().firstOrNull()
?: error("No configured potions available for this server version.")
}
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val enabled = ocm.config.getBoolean("old-potion-effects.enabled")
val strengthSection = ocm.config.getConfigurationSection("old-potion-effects.strength")
val weaknessSection = ocm.config.getConfigurationSection("old-potion-effects.weakness")
val drinkSection = ocm.config.getConfigurationSection("old-potion-effects.potion-durations.drinkable")
val splashSection = ocm.config.getConfigurationSection("old-potion-effects.potion-durations.splash")
val alwaysEnabledModules = ocm.config.getStringList("always_enabled_modules")
val disabledModules = ocm.config.getStringList("disabled_modules")
val modesetSection = ocm.config.getConfigurationSection("modesets")
val strengthSnapshot = strengthSection?.getValues(false) ?: emptyMap()
val weaknessSnapshot = weaknessSection?.getValues(false) ?: emptyMap()
val drinkSnapshot = drinkSection?.getKeys(false)?.associateWith { drinkSection.get(it) } ?: emptyMap()
val splashSnapshot = splashSection?.getKeys(false)?.associateWith { splashSection.get(it) } ?: emptyMap()
val modesetSnapshot = modesetSection?.getKeys(false)?.associateWith { key ->
ocm.config.getStringList("modesets.$key")
} ?: emptyMap()
try {
block()
} finally {
ocm.config.set("old-potion-effects.enabled", enabled)
strengthSnapshot.forEach { (key, value) ->
ocm.config.set("old-potion-effects.strength.$key", value)
}
weaknessSnapshot.forEach { (key, value) ->
ocm.config.set("old-potion-effects.weakness.$key", value)
}
drinkSnapshot.forEach { (key, value) ->
ocm.config.set("old-potion-effects.potion-durations.drinkable.$key", value)
}
splashSnapshot.forEach { (key, value) ->
ocm.config.set("old-potion-effects.potion-durations.splash.$key", value)
}
ocm.config.set("always_enabled_modules", alwaysEnabledModules)
ocm.config.set("disabled_modules", disabledModules)
modesetSnapshot.forEach { (key, list) ->
ocm.config.set("modesets.$key", list)
}
modesetSection?.getKeys(false)
?.filterNot { modesetSnapshot.containsKey(it) }
?.forEach { key -> ocm.config.set("modesets.$key", null) }
ocm.saveConfig()
Config.reload()
}
}
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun runSyncResult(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
}
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val location = Location(world, 0.0, 100.0, 0.0)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
player.isOp = true
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
}
}
afterSpec {
runSync {
fakePlayer.removePlayer()
}
}
beforeTest {
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
}
}
context("Drinkable potions") {
test("configured drinkable potions are adjusted when duration is loaded") {
val cases = loadPotionCases()
cases.isNotEmpty().shouldBeTrue()
cases.forEach { potionCase ->
val item = createPotionItem(Material.POTION, potionCase)
val meta = item.itemMeta as PotionMeta
val originalBase = snapshotBase(meta)
val adjusted = callConsume(item)
assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = false)
}
}
}
context("Weakness neutralisation") {
test("weakness potion does not reduce attack damage") {
val weaknessCase = loadPotionCases().firstOrNull { it.baseName == "WEAKNESS" }
?: return@test
val item = createPotionItem(Material.POTION, weaknessCase)
val adjusted = callConsume(item)
val meta = adjusted.itemMeta as PotionMeta
val effect = meta.customEffects.firstOrNull { it.type == PotionEffectType.WEAKNESS }
?: error("Weakness effect missing from potion meta")
val attackAttribute = XAttribute.ATTACK_DAMAGE.get()
?: error("Attack damage attribute not available")
var baseDamage = 0.0
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.AIR))
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
baseDamage = player.getAttribute(attackAttribute)?.value
?: error("Attack damage attribute missing on player")
player.addPotionEffect(effect, true)
}
delay(50)
var afterDamage = 0.0
runSync {
afterDamage = player.getAttribute(attackAttribute)?.value
?: error("Attack damage attribute missing on player")
}
afterDamage.shouldBe(baseDamage.plusOrMinus(0.0001))
}
test("direct weakness effect does not reduce attack damage") {
val attackAttribute = XAttribute.ATTACK_DAMAGE.get()
?: error("Attack damage attribute not available")
var baseDamage = 0.0
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.AIR))
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
baseDamage = player.getAttribute(attackAttribute)?.value
?: error("Attack damage attribute missing on player")
player.addPotionEffect(PotionEffect(PotionEffectType.WEAKNESS, 200, -1), true)
}
delay(50)
var afterDamage = 0.0
runSync {
afterDamage = player.getAttribute(attackAttribute)?.value
?: error("Attack damage attribute missing on player")
}
afterDamage.shouldBe(baseDamage.plusOrMinus(0.0001))
}
}
context("Weakness damage event diagnostic") {
test("vanilla damage event for weakness + low damage + no-damage window") {
lateinit var attacker: Player
lateinit var victim: LivingEntity
var attackerFake: FakePlayer? = null
val events = mutableListOf()
val listener = object : Listener {
@EventHandler
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId &&
event.damager.uniqueId == attacker.uniqueId
) {
events.add(event)
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val attackerLocation = Location(world, 0.0, 100.0, 0.0).apply { yaw = 0f; pitch = 0f }
val victimLocation = Location(world, 1.2, 100.0, 0.0)
attackerFake = FakePlayer(testPlugin)
attackerFake!!.spawn(attackerLocation)
attacker = checkNotNull(Bukkit.getPlayer(attackerFake!!.uuid))
attacker.isOp = true
attacker.inventory.clear()
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
attacker.gameMode = GameMode.SURVIVAL
val attackerData = getPlayerData(attacker.uniqueId)
attackerData.setModesetForWorld(attacker.world.uid, "old")
setPlayerData(attacker.uniqueId, attackerData)
victim = world.spawn(victimLocation, org.bukkit.entity.Cow::class.java)
victim.maximumNoDamageTicks = 20
victim.noDamageTicks = 0
victim.isInvulnerable = false
victim.health = victim.maxHealth
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
delay(200)
fun attackDamage(): Double {
val attribute = XAttribute.ATTACK_DAMAGE.get()
?: error("Attack damage attribute not available")
return attacker.getAttribute(attribute)?.value ?: 0.0
}
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION") // Deprecated constructor kept for older server compatibility in tests.
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = attacker.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun record(label: String, expectedDamage: Double, action: () -> Unit): Boolean {
val before = events.size
runSync {
Bukkit.getScheduler().runTask(testPlugin, Runnable { action() })
}
delay(150)
val fired = events.size > before
testPlugin.logger.info(
"Weakness diagnostic [$label] fired=$fired " +
"noDamageTicks=${victim.noDamageTicks} lastDamage=${victim.lastDamage} " +
"eventType=${events.lastOrNull()?.javaClass?.simpleName} " +
"cause=${events.lastOrNull()?.cause} " +
"eventDamage=${events.lastOrNull()?.damage} " +
"finalDamage=${events.lastOrNull()?.finalDamage} " +
"damagerType=${events.lastOrNull()?.damager?.javaClass?.simpleName} " +
"damagerId=${events.lastOrNull()?.damager?.uniqueId} " +
"inputDamage=$expectedDamage"
)
return fired
}
runSync {
val weapon = ItemStack(Material.DIAMOND_SWORD)
prepareWeapon(weapon)
attacker.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
attacker.updateInventory()
attacker.isInvulnerable = false
attacker.health = attacker.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
}
delay(100)
val baselineDamage = attackDamage()
waitForAttackReady(attacker)
record("baseline", baselineDamage) {
attackCompat(attacker, victim)
}
runSync {
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
attacker.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 200, 0))
val lowItem = ItemStack(Material.STONE_SWORD)
prepareWeapon(lowItem)
attacker.inventory.setItemInMainHand(lowItem)
applyAttackDamageModifiers(lowItem)
attacker.updateInventory()
attacker.isInvulnerable = false
attacker.health = attacker.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
}
delay(100)
val weakDamage = attackDamage()
waitForAttackReady(attacker)
record("weakness-no-invuln", weakDamage) {
attackCompat(attacker, victim)
}
runSync {
victim.noDamageTicks = victim.maximumNoDamageTicks
victim.lastDamage = 20.0
}
delay(100)
waitForAttackReady(attacker)
record("weakness-invuln", weakDamage) {
attackCompat(attacker, victim)
}
} finally {
HandlerList.unregisterAll(listener)
runSync {
attackerFake?.removePlayer()
victim.remove()
}
}
}
}
context("Splash potions") {
test("player throws splash potions with module durations") {
val cases = loadPotionCases()
cases.forEach { potionCase ->
val item = createPotionItem(Material.SPLASH_POTION, potionCase)
val meta = item.itemMeta as PotionMeta
val originalBase = snapshotBase(meta)
val adjusted = callThrow(item)
assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = true)
}
}
test("dispenser does not mutate splash potions without setItem") {
val cases = loadPotionCases()
cases.forEach { potionCase ->
val item = createPotionItem(Material.SPLASH_POTION, potionCase)
val meta = item.itemMeta as PotionMeta
val originalBase = snapshotBase(meta)
val adjusted = callDispense(item)
assertUnchanged(adjusted, originalBase)
}
}
}
context("Lingering potions") {
test("player throws lingering potions with module splash durations") {
val cases = loadPotionCases()
cases.forEach { potionCase ->
val item = createPotionItem(Material.LINGERING_POTION, potionCase)
val meta = item.itemMeta as PotionMeta
val originalBase = snapshotBase(meta)
val adjusted = callThrow(item)
assertAdjustedOrUnchanged(adjusted, potionCase, originalBase, splash = true)
}
}
test("dispenser does not mutate lingering potions without setItem") {
val cases = loadPotionCases()
cases.forEach { potionCase ->
val item = createPotionItem(Material.LINGERING_POTION, potionCase)
val meta = item.itemMeta as PotionMeta
val originalBase = snapshotBase(meta)
val adjusted = callDispense(item)
assertUnchanged(adjusted, originalBase)
}
}
}
context("Excluded potions") {
test("excluded potion types are not modified") {
excludedPotionTypes.forEach { name ->
val potionType = runCatching { PotionType.valueOf(name) }.getOrNull() ?: return@forEach
val item = ItemStack(Material.POTION)
val meta = item.itemMeta as PotionMeta
val originalBase = runCatching {
try {
meta.basePotionType = potionType
} catch (e: NoSuchMethodError) {
meta.basePotionData = PotionData(potionType, false, false)
}
snapshotBase(meta)
}.getOrElse { return@forEach }
item.itemMeta = meta
val adjusted = callConsume(item)
assertUnchanged(adjusted, originalBase)
}
}
}
context("Missing config") {
test("missing potion entry leaves potion unchanged") {
withConfig {
val potionCase = findSamplePotionCase()
ocm.config.set("old-potion-effects.potion-durations.drinkable.${potionCase.key}", null)
ocm.config.set("old-potion-effects.potion-durations.splash.${potionCase.key}", null)
module.reload()
val item = createPotionItem(Material.POTION, potionCase)
val originalBase = snapshotBase(item.itemMeta as PotionMeta)
val adjusted = callConsume(item)
assertUnchanged(adjusted, originalBase)
}
}
}
context("Module disabled") {
test("disabled via modeset leaves potions unchanged") {
val potionCase = findSamplePotionCase()
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "new")
setPlayerData(player.uniqueId, playerData)
val item = createPotionItem(Material.POTION, potionCase)
val originalBase = snapshotBase(item.itemMeta as PotionMeta)
val adjusted = callConsume(item)
assertUnchanged(adjusted, originalBase)
}
test("disabled via config leaves potions unchanged") {
withConfig {
val potionCase = findSamplePotionCase()
val disabled = ocm.config.getStringList("disabled_modules")
.filterNot { it.equals("old-potion-effects", true) }
.toMutableList()
disabled.add("old-potion-effects")
ocm.config.set("disabled_modules", disabled)
ocm.config.set(
"always_enabled_modules",
ocm.config.getStringList("always_enabled_modules")
.filterNot { it.equals("old-potion-effects", true) }
)
val modesetsSection = ocm.config.getConfigurationSection("modesets")
?: error("Missing 'modesets' section in config")
modesetsSection.getKeys(false).forEach { key ->
val modules = ocm.config.getStringList("modesets.$key")
.filterNot { it.equals("old-potion-effects", true) }
ocm.config.set("modesets.$key", modules)
}
ocm.saveConfig()
Config.reload()
val item = createPotionItem(Material.POTION, potionCase)
val originalBase = snapshotBase(item.itemMeta as PotionMeta)
val adjusted = callConsume(item)
assertUnchanged(adjusted, originalBase)
}
}
}
context("Strength and weakness modifiers") {
test("vanilla strength addend applies when old-potion-effects is disabled") {
withConfig {
val disabled = ocm.config.getStringList("disabled_modules")
.filterNot { it.equals("old-potion-effects", true) }
.toMutableList()
disabled.add("old-potion-effects")
ocm.config.set("disabled_modules", disabled)
ocm.config.set(
"always_enabled_modules",
ocm.config.getStringList("always_enabled_modules")
.filterNot { it.equals("old-potion-effects", true) }
)
val modesetsSection = ocm.config.getConfigurationSection("modesets")
?: error("Missing 'modesets' section in config")
modesetsSection.getKeys(false).forEach { key ->
val modules = ocm.config.getStringList("modesets.$key")
.filterNot { it.equals("old-potion-effects", true) }
ocm.config.set("modesets.$key", modules)
}
ocm.saveConfig()
Config.reload()
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val weapon = ItemStack(Material.DIAMOND_SWORD)
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION")
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun captureDamage(victim: LivingEntity): Double {
val events = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == player.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
try {
waitForAttackReady(player)
runSync {
attackCompat(player, victim)
}
delay(200)
} finally {
HandlerList.unregisterAll(listener)
}
val event = events.lastOrNull()
?: error("Expected a damage event for vanilla strength test")
return event.damage
}
prepareWeapon(weapon)
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
player.updateInventory()
player.isSprinting = false
player.fallDistance = 0f
player.velocity = Vector(0.0, 0.0, 0.0)
player.isInvulnerable = false
player.health = player.maxHealth
}
val baselineVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
val baselineDamage = captureDamage(baselineVictim)
runSync { baselineVictim.remove() }
val strengthVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)
}
delay(50)
val strengthDamage = captureDamage(strengthVictim)
runSync { strengthVictim.remove() }
val delta = strengthDamage - baselineDamage
delta.shouldBe(6.0.plusOrMinus(0.0001))
}
}
test("damage modifiers are applied from config") {
withConfig {
val strengthModifier = 2.4
val weaknessModifier = -0.75
ocm.config.set("old-potion-effects.strength.modifier", strengthModifier)
ocm.config.set("old-potion-effects.strength.multiplier", false)
ocm.config.set("old-potion-effects.strength.addend", true)
ocm.config.set("old-potion-effects.weakness.modifier", weaknessModifier)
ocm.config.set("old-potion-effects.weakness.multiplier", true)
module.reload()
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 0))
player.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 200, 0))
val defender = player
val event = OCMEntityDamageByEntityEvent(
player,
defender,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
4.0
)
Bukkit.getPluginManager().callEvent(event)
event.strengthModifier.shouldBe(strengthModifier)
event.isStrengthModifierMultiplier.shouldBeFalse()
event.isStrengthModifierAddend.shouldBeTrue()
event.weaknessModifier.shouldBe(weaknessModifier)
event.isWeaknessModifierMultiplier.shouldBeTrue()
event.weaknessLevel.shouldBe(1)
}
}
test("strength addend scales per level for Strength II") {
withConfig {
val strengthModifier = 2.0
ocm.config.set("old-potion-effects.strength.modifier", strengthModifier)
ocm.config.set("old-potion-effects.strength.multiplier", false)
ocm.config.set("old-potion-effects.strength.addend", true)
module.reload()
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val weapon = ItemStack(Material.DIAMOND_SWORD)
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION")
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun captureDamage(victim: LivingEntity): Double {
val events = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == player.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
try {
waitForAttackReady(player)
runSync {
attackCompat(player, victim)
}
delay(200)
} finally {
HandlerList.unregisterAll(listener)
}
val event = events.lastOrNull()
?: error("Expected a damage event for strength addend test")
return event.damage
}
prepareWeapon(weapon)
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
player.updateInventory()
player.isSprinting = false
player.fallDistance = 0f
player.velocity = Vector(0.0, 0.0, 0.0)
player.isInvulnerable = false
player.health = player.maxHealth
}
val baselineVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
val baselineDamage = captureDamage(baselineVictim)
runSync { baselineVictim.remove() }
val strengthVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)
}
delay(50)
val strengthDamage = captureDamage(strengthVictim)
runSync { strengthVictim.remove() }
val delta = strengthDamage - baselineDamage
delta.shouldBe((strengthModifier * 2).plusOrMinus(0.0001))
}
}
test("strength addend scales per level for Strength III") {
withConfig {
val strengthModifier = 2.0
ocm.config.set("old-potion-effects.strength.modifier", strengthModifier)
ocm.config.set("old-potion-effects.strength.multiplier", false)
ocm.config.set("old-potion-effects.strength.addend", true)
module.reload()
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val weapon = ItemStack(Material.DIAMOND_SWORD)
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION")
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun captureDamage(victim: LivingEntity): Double {
val events = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == player.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
try {
waitForAttackReady(player)
runSync {
attackCompat(player, victim)
}
delay(200)
} finally {
HandlerList.unregisterAll(listener)
}
val event = events.lastOrNull()
?: error("Expected a damage event for strength addend test")
return event.damage
}
prepareWeapon(weapon)
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
player.updateInventory()
player.isSprinting = false
player.fallDistance = 0f
player.velocity = Vector(0.0, 0.0, 0.0)
player.isInvulnerable = false
player.health = player.maxHealth
}
val baselineVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
val baselineDamage = captureDamage(baselineVictim)
runSync { baselineVictim.remove() }
val strengthVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 2), true)
}
delay(50)
val strengthDamage = captureDamage(strengthVictim)
runSync { strengthVictim.remove() }
val delta = strengthDamage - baselineDamage
delta.shouldBe((strengthModifier * 3).plusOrMinus(0.0001))
}
}
test("strength addend respects configured modifier value") {
withConfig {
val strengthModifier = 4.5
ocm.config.set("old-potion-effects.strength.modifier", strengthModifier)
ocm.config.set("old-potion-effects.strength.multiplier", false)
ocm.config.set("old-potion-effects.strength.addend", true)
module.reload()
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val weapon = ItemStack(Material.DIAMOND_SWORD)
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION")
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun captureDamage(victim: LivingEntity): Double {
val events = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == player.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
try {
waitForAttackReady(player)
runSync {
attackCompat(player, victim)
}
delay(200)
} finally {
HandlerList.unregisterAll(listener)
}
val event = events.lastOrNull()
?: error("Expected a damage event for strength addend test")
return event.damage
}
prepareWeapon(weapon)
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
player.updateInventory()
player.isSprinting = false
player.fallDistance = 0f
player.velocity = Vector(0.0, 0.0, 0.0)
player.isInvulnerable = false
player.health = player.maxHealth
}
val baselineVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
val baselineDamage = captureDamage(baselineVictim)
runSync { baselineVictim.remove() }
val strengthVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 0), true)
}
delay(50)
val strengthDamage = captureDamage(strengthVictim)
runSync { strengthVictim.remove() }
val delta = strengthDamage - baselineDamage
delta.shouldBe(strengthModifier.plusOrMinus(0.0001))
}
}
test("strength multiplier scales base damage") {
withConfig {
val strengthModifier = 1.4
ocm.config.set("old-potion-effects.strength.modifier", strengthModifier)
ocm.config.set("old-potion-effects.strength.multiplier", true)
ocm.config.set("old-potion-effects.strength.addend", false)
module.reload()
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
val weapon = ItemStack(Material.DIAMOND_SWORD)
fun prepareWeapon(item: ItemStack) {
val meta = item.itemMeta ?: return
@Suppress("DEPRECATION")
val speedModifier = createAttributeModifier(
name = "speed",
amount = 1000.0,
operation = AttributeModifier.Operation.ADD_NUMBER,
slot = EquipmentSlot.HAND
)
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
item.itemMeta = meta
}
fun applyAttackDamageModifiers(item: ItemStack) {
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
modifiers.forEach { modifier ->
attackAttribute.removeModifier(modifier)
attackAttribute.addModifier(modifier)
}
}
suspend fun captureDamage(victim: LivingEntity): Double {
val events = mutableListOf()
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager.uniqueId == player.uniqueId &&
event.entity.uniqueId == victim.uniqueId
) {
events.add(event)
}
}
}
runSync {
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
}
try {
waitForAttackReady(player)
runSync {
attackCompat(player, victim)
}
delay(200)
} finally {
HandlerList.unregisterAll(listener)
}
val event = events.lastOrNull()
?: error("Expected a damage event for strength multiplier test")
return event.damage
}
prepareWeapon(weapon)
runSync {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.setItemInMainHand(weapon)
applyAttackDamageModifiers(weapon)
player.updateInventory()
player.isSprinting = false
player.fallDistance = 0f
player.velocity = Vector(0.0, 0.0, 0.0)
player.isInvulnerable = false
player.health = player.maxHealth
}
val baselineVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
val baselineDamage = captureDamage(baselineVictim)
runSync { baselineVictim.remove() }
val strengthVictim = runSyncResult {
world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Cow::class.java).apply {
maximumNoDamageTicks = 20
noDamageTicks = 0
isInvulnerable = false
health = maxHealth
}
}
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 200, 1), true)
}
delay(50)
val strengthDamage = captureDamage(strengthVictim)
runSync { strengthVictim.remove() }
val ratio = strengthDamage / baselineDamage
ratio.shouldBe((strengthModifier * 2).plusOrMinus(0.0001))
}
}
test("weakness II is capped to level one for old modifier logic") {
withConfig {
val weaknessModifier = -0.5
ocm.config.set("old-potion-effects.weakness.modifier", weaknessModifier)
ocm.config.set("old-potion-effects.weakness.multiplier", false)
module.reload()
val weakness = XPotion.WEAKNESS.get() ?: error("Weakness potion missing")
runSync {
player.addPotionEffect(PotionEffect(weakness, 200, 1), true)
}
val event = OCMEntityDamageByEntityEvent(
player,
player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
4.0
)
Bukkit.getPluginManager().callEvent(event)
event.hasWeakness().shouldBeTrue()
event.weaknessModifier.shouldBe(weaknessModifier)
event.isWeaknessModifierMultiplier.shouldBeFalse()
event.weaknessLevel.shouldBe(1)
}
}
test("high amplifier weakness does not distort base damage reconstruction") {
withConfig {
val weakness = XPotion.WEAKNESS.get() ?: error("Weakness potion missing")
var baseLevel0 = 0.0
var baseLevel67 = 0.0
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(weakness, 200, 0), true)
}
val eventLevel0 = OCMEntityDamageByEntityEvent(
player,
player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
4.0
)
Bukkit.getPluginManager().callEvent(eventLevel0)
baseLevel0 = eventLevel0.baseDamage
runSync {
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.addPotionEffect(PotionEffect(weakness, 200, 67), true)
}
val eventLevel67 = OCMEntityDamageByEntityEvent(
player,
player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
4.0
)
Bukkit.getPluginManager().callEvent(eventLevel67)
baseLevel67 = eventLevel67.baseDamage
baseLevel67.shouldBe(baseLevel0.plusOrMinus(0.0001))
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/OldToolDamageMobIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Entity
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Villager
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.lang.reflect.Method
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class OldToolDamageMobIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val damagesSection = ocm.config.getConfigurationSection("old-tool-damage.damages")
val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()
val disabledModules = ocm.config.getStringList("disabled_modules")
val modesetsSection =
ocm.config.getConfigurationSection("modesets")
?: error("Missing 'modesets' section in config")
val modesetSnapshot =
modesetsSection.getKeys(false).associateWith { key ->
ocm.config.getStringList("modesets.$key")
}
fun reloadAll() {
ocm.saveConfig()
Config.reload()
}
try {
block()
} finally {
damagesSnapshot.forEach { (key, value) ->
ocm.config.set("old-tool-damage.damages.$key", value)
}
ocm.config.set("disabled_modules", disabledModules)
modesetSnapshot.forEach { (key, list) ->
ocm.config.set("modesets.$key", list)
}
reloadAll()
}
}
fun mobMethodSignature(method: Method): String {
val params = method.parameterTypes.joinToString(",") { it.name }
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
}
fun mobCollectAllMethods(start: Class<*>): List {
val methods = LinkedHashMap()
var current: Class<*>? = start
while (current != null) {
current.declaredMethods.forEach { method ->
methods.putIfAbsent(mobMethodSignature(method), method)
}
current = current.superclass
}
start.methods.forEach { method ->
methods.putIfAbsent(mobMethodSignature(method), method)
}
return methods.values.toList()
}
fun mobScoreAttackMethod(method: Method): Int {
var score = 0
if (method.name == "attack") score += 100
if (method.name == "a") score += 80
val param = method.parameterTypes[0]
if (param.simpleName == "Entity") score += 40
if (param.simpleName.contains("Entity")) score += 10
if (method.returnType == Void.TYPE) score += 10
if (method.returnType == java.lang.Boolean.TYPE) score += 8
val declaring = method.declaringClass.simpleName
if (declaring.contains("EntityInsentient")) score += 20
if (declaring.contains("Mob")) score += 10
return score
}
fun resolveDebugFile(): java.io.File {
val versionTag = Bukkit.getBukkitVersion().replace(Regex("[^A-Za-z0-9_.-]"), "_")
val runDir = java.io.File(System.getProperty("user.dir"))
val repoRoot = runDir.parentFile?.parentFile ?: runDir
return java.io.File(repoRoot, "build/mob-tool-damage-debug-$versionTag.txt")
}
fun attackCompat(
attacker: LivingEntity,
target: Entity,
): Boolean {
val handleMethod =
attacker.javaClass.methods.firstOrNull { method ->
method.name == "getHandle" && method.parameterTypes.isEmpty()
} ?: error("Failed to resolve CraftEntity#getHandle for ${attacker.javaClass.name}")
val attackerHandle =
handleMethod.invoke(attacker)
?: error("CraftEntity#getHandle returned null for ${attacker.javaClass.name}")
val targetHandle =
target.javaClass.methods
.firstOrNull { method ->
method.name == "getHandle" && method.parameterTypes.isEmpty()
}?.invoke(target) ?: error("Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}")
val candidates =
listOfNotNull(
Reflector.getMethodAssignable(attackerHandle.javaClass, "attack", targetHandle.javaClass),
Reflector.getMethodAssignable(attackerHandle.javaClass, "a", targetHandle.javaClass),
).ifEmpty {
mobCollectAllMethods(attackerHandle.javaClass)
.asSequence()
.filter { it.parameterCount == 1 }
.filter { it.parameterTypes[0].isAssignableFrom(targetHandle.javaClass) }
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
.sortedByDescending { mobScoreAttackMethod(it) }
.toList()
}
candidates.forEach { it.isAccessible = true }
for (method in candidates) {
try {
val result = method.invoke(attackerHandle, targetHandle)
if (result is Boolean && !result) {
continue
}
return true
} catch (ignored: Exception) {
// try next
}
}
return false
}
suspend fun captureVindicatorBaseDamage(
debugFile: java.io.File,
label: String,
): Double {
val mobClass: Class =
try {
@Suppress("UNCHECKED_CAST")
Class.forName("org.bukkit.entity.Vindicator") as Class
} catch (_: ClassNotFoundException) {
org.bukkit.entity.Zombie::class.java
}
lateinit var victim: Villager
lateinit var mob: LivingEntity
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val victimLocation = Location(world, 0.0, 100.0, 0.0)
val mobLocation = Location(world, 1.1, 100.0, 0.0)
victim = world.spawn(victimLocation, Villager::class.java)
victim.isInvulnerable = false
victim.health = victim.maxHealth
victim.maximumNoDamageTicks = 0
victim.noDamageTicks = 0
mob = world.spawn(mobLocation, mobClass)
mob.isSilent = true
mob.equipment?.setItemInMainHand(ItemStack(Material.IRON_AXE))
mob.maximumNoDamageTicks = 0
mob.noDamageTicks = 0
}
try {
val baseDamage = NewWeaponDamage.getDamage(Material.IRON_AXE) // vanilla 1.9 base
val event =
EntityDamageByEntityEvent(
mob,
victim,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
baseDamage.toDouble(),
)
runSync {
Bukkit.getPluginManager().callEvent(event)
}
val moduleEnabled = Config.moduleEnabled("old-tool-damage", victim.world)
debugFile.parentFile?.mkdirs()
debugFile.appendText(
"label=$label base=${event.damage} raw=${event.damage} weapon=IRON_AXE enabled=$moduleEnabled\n",
)
return event.damage
} finally {
runSync {
mob.remove()
victim.remove()
}
}
}
test("mob tool damage follows configured old-tool-damage values") {
withConfig {
val debugFile = resolveDebugFile()
debugFile.parentFile?.mkdirs()
debugFile.writeText("start\n")
try {
ocm.config.set(
"disabled_modules",
ocm.config
.getStringList("disabled_modules")
.filterNot { it.equals("old-tool-damage", true) },
)
val oldModeset =
ocm.config
.getStringList("modesets.old")
.filterNot { it.equals("old-tool-damage", true) }
.toMutableList()
oldModeset.add("old-tool-damage")
ocm.config.set("modesets.old", oldModeset)
ocm.config.set("old-tool-damage.damages.IRON_AXE", 1)
ocm.saveConfig()
Config.reload()
val lowDamage =
try {
captureVindicatorBaseDamage(debugFile, "low")
} catch (e: Throwable) {
debugFile.appendText("low-error=${e::class.java.simpleName}: ${e.message}\n")
throw e
}
ocm.config.set("old-tool-damage.damages.IRON_AXE", 20)
ocm.saveConfig()
Config.reload()
val highDamage =
try {
captureVindicatorBaseDamage(debugFile, "high")
} catch (e: Throwable) {
debugFile.appendText("high-error=${e::class.java.simpleName}: ${e.message}\n")
throw e
}
val delta = highDamage - lowDamage
debugFile.appendText("delta=$delta low=$lowDamage high=$highDamage\n")
if (delta <= 10.0) {
error("Mob tool damage delta too small: delta=$delta low=$lowDamage high=$highDamage")
}
} catch (e: Throwable) {
debugFile.appendText("test-error=${e::class.java.simpleName}: ${e.message}\n")
throw e
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PacketCancellationIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.github.retrooper.packetevents.PacketEvents
import com.github.retrooper.packetevents.event.PacketSendEvent
import com.github.retrooper.packetevents.manager.server.ServerVersion
import com.github.retrooper.packetevents.netty.buffer.ByteBufHelper
import com.github.retrooper.packetevents.protocol.ConnectionState
import com.github.retrooper.packetevents.protocol.PacketSide
import com.github.retrooper.packetevents.protocol.packettype.PacketType
import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon
import com.github.retrooper.packetevents.protocol.particle.Particle
import com.github.retrooper.packetevents.protocol.particle.type.ParticleTypes
import com.github.retrooper.packetevents.protocol.sound.Sound
import com.github.retrooper.packetevents.protocol.sound.SoundCategory
import com.github.retrooper.packetevents.protocol.sound.Sounds
import com.github.retrooper.packetevents.protocol.player.User
import com.github.retrooper.packetevents.protocol.player.UserProfile
import com.github.retrooper.packetevents.util.Vector3d
import com.github.retrooper.packetevents.util.Vector3f
import com.github.retrooper.packetevents.util.Vector3i
import com.github.retrooper.packetevents.wrapper.PacketWrapper
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerParticle
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSoundEffect
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackSounds
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweepParticles
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class PacketCancellationIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as OCMMain
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
require(PacketEvents.getAPI().isInitialized) { "PacketEvents not initialised for integration tests." }
}
suspend fun withModuleState(
moduleName: String,
enabled: Boolean,
preReload: (() -> Unit)? = null,
postRestore: (() -> Unit)? = null,
block: suspend () -> Unit
) {
val disabledOriginal = ocm.config.getStringList("disabled_modules")
val alwaysOriginal = ocm.config.getStringList("always_enabled_modules")
val modesetsSection = ocm.config.getConfigurationSection("modesets")
?: error("Missing modesets section in config")
val modesetsOriginal = modesetsSection.getKeys(false).associateWith {
ocm.config.getStringList("modesets.$it")
}
fun List.withoutModule(): MutableList =
filterNot { it.equals(moduleName, true) }.toMutableList()
val disabledUpdated = disabledOriginal.withoutModule()
val alwaysUpdated = alwaysOriginal.withoutModule()
if (enabled) {
alwaysUpdated.add(moduleName)
} else {
disabledUpdated.add(moduleName)
}
ocm.config.set("disabled_modules", disabledUpdated)
ocm.config.set("always_enabled_modules", alwaysUpdated)
modesetsOriginal.keys.forEach { key ->
val filtered = modesetsOriginal.getValue(key).withoutModule()
ocm.config.set("modesets.$key", filtered)
}
preReload?.invoke()
ocm.saveConfig()
Config.reload()
delay(2 * 50L)
try {
block()
} finally {
ocm.config.set("disabled_modules", disabledOriginal)
ocm.config.set("always_enabled_modules", alwaysOriginal)
modesetsOriginal.forEach { (key, value) ->
ocm.config.set("modesets.$key", value)
}
postRestore?.invoke()
ocm.saveConfig()
Config.reload()
delay(2 * 50L)
}
}
fun spawnFakePlayer(): Pair {
val world = Bukkit.getWorld("world") ?: error("world not loaded")
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
val fake = FakePlayer(testPlugin)
fake.spawn(location)
val player = Bukkit.getPlayer(fake.uuid) ?: error("Player not found after spawn")
return fake to player
}
suspend fun removeFakePlayer(fake: FakePlayer) {
runSync { fake.removePlayer() }
delay(2 * 50L)
}
suspend fun withBlockedSounds(module: ModuleAttackSounds, blocked: Set, block: suspend () -> Unit) {
val field = module.javaClass.getDeclaredField("blockedSounds")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val current = field.get(module) as MutableSet
val snapshot = current.toSet()
current.clear()
current.addAll(blocked)
try {
block()
} finally {
current.clear()
current.addAll(snapshot)
}
}
fun createPacketSendEvent(
packetId: Int,
packetType: PacketTypeCommon,
serverVersion: ServerVersion,
channel: Any,
user: User,
player: Player,
buffer: Any
): PacketSendEvent {
val constructor = PacketSendEvent::class.java.getDeclaredConstructor(
Int::class.javaPrimitiveType,
PacketTypeCommon::class.java,
ServerVersion::class.java,
Any::class.java,
User::class.java,
Any::class.java,
Any::class.java
)
constructor.isAccessible = true
return constructor.newInstance(
packetId,
packetType,
serverVersion,
channel,
user,
player,
buffer
) as PacketSendEvent
}
fun runPacketThroughPacketEvents(player: Player, wrapper: PacketWrapper<*>): Boolean {
val api = PacketEvents.getAPI()
val channel = api.playerManager.getChannel(player) ?: error("Missing channel for ${player.name}")
val serverVersion = api.serverManager.version
val user = User(
channel,
ConnectionState.PLAY,
serverVersion.toClientVersion(),
UserProfile(player.uniqueId, player.name)
)
user.setEncoderState(ConnectionState.PLAY)
user.setDecoderState(ConnectionState.PLAY)
user.setEntityId(player.entityId)
val buffers = api.protocolManager.transformWrappers(wrapper, channel, true)
val buffer = buffers.firstOrNull() ?: error("No buffer produced for ${wrapper.javaClass.simpleName}")
val packetId = ByteBufHelper.readVarInt(buffer)
val packetType = PacketType.getById(PacketSide.SERVER, ConnectionState.PLAY, user.clientVersion, packetId)
?: error("No packet type for id $packetId in ${user.clientVersion}")
val event = createPacketSendEvent(packetId, packetType, serverVersion, channel, user, player, buffer)
api.eventManager.callEvent(event)
return event.isCancelled
}
fun soundName(sound: Sound): String = sound.soundId.toString()
test("sweep particles are cancelled when module enabled") {
withModuleState("disable-sword-sweep-particles", enabled = true) {
val (fake, player) = runSync { spawnFakePlayer() }
try {
val particle = Particle(ParticleTypes.SWEEP_ATTACK)
val position = Vector3d(player.location.x, player.location.y, player.location.z)
val offset = Vector3f(0f, 0f, 0f)
val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)
val cancelled = runPacketThroughPacketEvents(player, wrapper)
cancelled shouldBe true
} finally {
removeFakePlayer(fake)
}
}
}
test("sweep particles are not cancelled when module disabled") {
withModuleState("disable-sword-sweep-particles", enabled = false) {
val (fake, player) = runSync { spawnFakePlayer() }
try {
val particle = Particle(ParticleTypes.FLAME)
val position = Vector3d(player.location.x, player.location.y, player.location.z)
val offset = Vector3f(0f, 0f, 0f)
val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)
val cancelled = runPacketThroughPacketEvents(player, wrapper)
cancelled shouldBe false
} finally {
removeFakePlayer(fake)
}
}
}
test("blocked attack sounds are cancelled when module enabled") {
withModuleState("disable-attack-sounds", enabled = true) {
val (fake, player) = runSync { spawnFakePlayer() }
try {
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModuleAttackSounds not registered")
val sound = Sounds.ENTITY_PLAYER_ATTACK_STRONG
val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)
val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.37f, 0.71f)
withBlockedSounds(module, setOf(soundName(sound))) {
val cancelled = runPacketThroughPacketEvents(player, wrapper)
cancelled shouldBe true
}
} finally {
removeFakePlayer(fake)
}
}
}
test("non-blocked attack sounds are not cancelled when module enabled") {
withModuleState("disable-attack-sounds", enabled = true) {
val (fake, player) = runSync { spawnFakePlayer() }
try {
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModuleAttackSounds not registered")
val blockedSound = Sounds.ENTITY_PLAYER_ATTACK_STRONG
val sound = Sounds.ENTITY_PLAYER_LEVELUP
val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)
val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.43f, 0.21f)
withBlockedSounds(module, setOf(soundName(blockedSound))) {
val cancelled = runPacketThroughPacketEvents(player, wrapper)
cancelled shouldBe false
}
} finally {
removeFakePlayer(fake)
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PaperSwordBlockingDamageReductionIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.shouldBeLessThan
import io.kotest.matchers.doubles.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.block.Action
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class PaperSwordBlockingDamageReductionIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler()
.callSyncMethod(testPlugin, Callable { action() })
.get()
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
fun paperDataComponentApiPresent(): Boolean {
return try {
Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
true
} catch (_: Throwable) {
false
}
}
fun setModeset(player: Player, name: String) {
val data = getPlayerData(player.uniqueId)
data.setModesetForWorld(player.world.uid, name)
setPlayerData(player.uniqueId, data)
}
fun equipSword(player: Player, material: Material) {
player.inventory.setItemInMainHand(ItemStack(material))
player.updateInventory()
}
fun rightClickMainHand(player: Player) {
val event = PlayerInteractEvent(
player,
Action.RIGHT_CLICK_AIR,
player.inventory.itemInMainHand,
null,
org.bukkit.block.BlockFace.SELF,
EquipmentSlot.HAND
)
Bukkit.getPluginManager().callEvent(event)
}
lateinit var defenderFake: FakePlayer
lateinit var defender: Player
lateinit var module: ModuleSwordBlocking
beforeSpec {
runSync {
module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModuleSwordBlocking not registered")
val world = Bukkit.getWorld("world") ?: error("world missing")
val base = Location(world, 0.0, 100.0, 0.0)
defenderFake = FakePlayer(testPlugin)
defenderFake.spawn(base)
defender = Bukkit.getPlayer(defenderFake.uuid) ?: error("defender not found")
defender.gameMode = GameMode.SURVIVAL
defender.maximumNoDamageTicks = 20
defender.noDamageTicks = 0
defender.isInvulnerable = false
defender.inventory.clear()
setModeset(defender, "old")
}
}
afterSpec {
runSync {
defenderFake.removePlayer()
}
}
test("Paper sword blocking sets BLOCKING modifier negative on hit") {
if (!paperDataComponentApiPresent()) {
println("Skipping: Paper DataComponent API not present")
return@test
}
runSync {
equipSword(defender, Material.DIAMOND_SWORD)
defender.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
runSync { rightClickMainHand(defender) }
delayTicks(2)
val offhandAfter = runSync { defender.inventory.itemInOffHand.type }
if (offhandAfter == Material.SHIELD) {
println("Skipping: legacy shield swap path is active on this server")
return@test
}
runSync {
// The bug we saw in live logs: the sword can animate as BLOCK, but the server may not recognise
// it as "blocking" for damage reduction (BLOCKING stays 0). This asserts the Paper path is
// actually recognised server-side.
module.isPaperSwordBlocking(defender) shouldBe true
}
val zombie = runSync {
defender.world.spawn(defender.location.clone().add(0.0, 0.0, 1.0), org.bukkit.entity.Zombie::class.java)
}
try {
// Use a synthetic event here. We specifically care about the Paper sword-block detection and the
// computed reduction. Whether Bukkit considers the BLOCKING modifier "applicable" is decided by
// the server's internal damage pipeline and can differ for synthetic events vs real hits.
val event = runSync {
org.bukkit.event.entity.EntityDamageByEntityEvent(
zombie,
defender,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
2.5
)
}
val reduction = runSync { module.applyPaperBlockingReduction(event, 2.5) }
reduction shouldBeGreaterThan 0.0
// Also sanity-check sign: the module is supposed to write BLOCKING as a negative modifier downstream.
// (We don't assert it here because synthetic events may not expose/apply that modifier consistently.)
reduction shouldBeLessThan 2.5
} finally {
runSync {
zombie.remove()
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PlayerKnockbackIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModulePlayerKnockback
import com.cryptomorin.xseries.XAttribute
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.AttributeModifier
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerVelocityEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.util.Vector
import java.util.UUID
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class PlayerKnockbackIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModulePlayerKnockback not registered")
lateinit var attacker: Player
lateinit var victim: Player
lateinit var fakeAttacker: FakePlayer
lateinit var fakeVictim: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
val horizontal = ocm.config.getDouble("old-player-knockback.knockback-horizontal")
val vertical = ocm.config.getDouble("old-player-knockback.knockback-vertical")
val verticalLimit = ocm.config.getDouble("old-player-knockback.knockback-vertical-limit")
val extraHorizontal = ocm.config.getDouble("old-player-knockback.knockback-extra-horizontal")
val extraVertical = ocm.config.getDouble("old-player-knockback.knockback-extra-vertical")
val resistanceEnabled = ocm.config.getBoolean("old-player-knockback.enable-knockback-resistance")
try {
block()
} finally {
ocm.config.set("old-player-knockback.knockback-horizontal", horizontal)
ocm.config.set("old-player-knockback.knockback-vertical", vertical)
ocm.config.set("old-player-knockback.knockback-vertical-limit", verticalLimit)
ocm.config.set("old-player-knockback.knockback-extra-horizontal", extraHorizontal)
ocm.config.set("old-player-knockback.knockback-extra-vertical", extraVertical)
ocm.config.set("old-player-knockback.enable-knockback-resistance", resistanceEnabled)
module.reload()
ModuleLoader.toggleModules()
}
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
fun pendingKnockbackField(): java.lang.reflect.Field {
val names = listOf("pendingKnockback", "playerKnockbackHashMap")
for (name in names) {
val f = runCatching { ModulePlayerKnockback::class.java.getDeclaredField(name) }.getOrNull() ?: continue
f.isAccessible = true
return f
}
error("No pending knockback field found on ModulePlayerKnockback (tried: $names)")
}
fun pendingKnockbackMap(): MutableMap {
val field = pendingKnockbackField()
@Suppress("UNCHECKED_CAST")
return field.get(module) as MutableMap
}
fun getPendingVector(uuid: UUID): Vector? {
val map = pendingKnockbackMap()
val value = map[uuid] ?: return null
return when (value) {
is Vector -> value
else -> {
val vf = value.javaClass.getDeclaredField("velocity")
vf.isAccessible = true
vf.get(value) as? Vector
}
}
}
fun removePending(uuid: UUID) {
pendingKnockbackMap().remove(uuid)
}
fun putPending(uuid: UUID, vector: Vector) {
val fieldName = pendingKnockbackField().name
if (fieldName == "playerKnockbackHashMap") {
@Suppress("UNCHECKED_CAST")
(pendingKnockbackMap() as MutableMap)[uuid] = vector
return
}
val pendingClass = ModulePlayerKnockback::class.java.declaredClasses
.firstOrNull { it.simpleName == "PendingKnockback" }
?: error("PendingKnockback inner class not found")
val ctor = pendingClass.getDeclaredConstructor(Vector::class.java, Long::class.javaPrimitiveType)
ctor.isAccessible = true
val pending = ctor.newInstance(vector, Long.MAX_VALUE)
pendingKnockbackMap()[uuid] = pending
}
fun damageEvent(): EntityDamageByEntityEvent {
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
Bukkit.getPluginManager().callEvent(event)
return event
}
fun velocityEvent(initial: Vector): PlayerVelocityEvent {
val event = PlayerVelocityEvent(victim, initial)
Bukkit.getPluginManager().callEvent(event)
return event
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)
val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)
fakeAttacker = FakePlayer(testPlugin)
fakeVictim = FakePlayer(testPlugin)
fakeAttacker.spawn(attackerLocation)
fakeVictim.spawn(victimLocation)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))
attacker.isOp = true
victim.isOp = true
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
setModeset(attacker, "old")
setModeset(victim, "old")
}
}
afterSpec {
runSync {
fakeAttacker.removePlayer()
fakeVictim.removePlayer()
}
}
beforeTest {
runSync {
val world = Bukkit.getServer().getWorld("world")
val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)
val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)
attacker.teleport(attackerLocation)
victim.teleport(victimLocation)
attacker.isSprinting = false
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
attacker.velocity = Vector(0, 0, 0)
victim.velocity = Vector(0, 0, 0)
victim.noDamageTicks = 0
victim.maximumNoDamageTicks = 0
victim.isInvulnerable = false
setModeset(attacker, "old")
setModeset(victim, "old")
module.reload()
}
}
context("Knockback vectors") {
test("base knockback is applied on velocity event") {
withConfig {
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
module.reload()
damageEvent()
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
vector.x shouldBe (0.4 plusOrMinus 0.0001)
vector.y shouldBe (0.4 plusOrMinus 0.0001)
vector.z shouldBe (0.0 plusOrMinus 0.0001)
removePending(victim.uniqueId)
}
}
test("sprint adds extra knockback") {
withConfig {
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
module.reload()
attacker.isSprinting = true
attacker.teleport(attacker.location.apply { yaw = 0f })
val event = EntityDamageByEntityEvent(
attacker,
victim,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
4.0
)
module.onEntityDamageEntity(event)
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
vector.x shouldBe (0.4 plusOrMinus 0.0001)
vector.y shouldBe (0.5 plusOrMinus 0.0001)
vector.z shouldBe (0.5 plusOrMinus 0.0001)
removePending(victim.uniqueId)
}
}
test("velocity override only applies once") {
withConfig {
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
module.reload()
val expected = Vector(0.4, 0.4, 0.0)
putPending(victim.uniqueId, expected)
val first = velocityEvent(Vector(0, 0, 0))
first.velocity.x shouldBe (0.4 plusOrMinus 0.0001)
val secondInitial = Vector(1.0, 2.0, 3.0)
val second = velocityEvent(secondInitial)
second.velocity.x shouldBe (1.0 plusOrMinus 0.0001)
second.velocity.y shouldBe (2.0 plusOrMinus 0.0001)
second.velocity.z shouldBe (3.0 plusOrMinus 0.0001)
}
}
}
context("Knockback resistance") {
test("modifiers are removed when resistance disabled") {
withConfig {
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
module.reload()
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
val attribute = victim.getAttribute(attributeType)
val modifier = AttributeModifier(UUID.randomUUID(), "test", 0.5, AttributeModifier.Operation.ADD_NUMBER)
attribute?.addModifier(modifier)
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
Bukkit.getPluginManager().callEvent(event)
attribute?.modifiers?.contains(modifier) shouldBe false
}
}
test("modifiers remain when resistance enabled and supported") {
if (!Reflector.versionIsNewerOrEqualTo(1, 16, 0)) return@test
withConfig {
ocm.config.set("old-player-knockback.enable-knockback-resistance", true)
module.reload()
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
val attribute = victim.getAttribute(attributeType)
val modifier = AttributeModifier(UUID.randomUUID(), "test", 0.5, AttributeModifier.Operation.ADD_NUMBER)
attribute?.addModifier(modifier)
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
Bukkit.getPluginManager().callEvent(event)
attribute?.modifiers?.contains(modifier) shouldBe true
}
}
test("enabled resistance scales horizontal knockback") {
if (!Reflector.versionIsNewerOrEqualTo(1, 16, 0)) return@test
withConfig {
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
ocm.config.set("old-player-knockback.enable-knockback-resistance", true)
module.reload()
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
val attribute = victim.getAttribute(attributeType) ?: return@withConfig
val originalBase = attribute.baseValue
attribute.baseValue = 0.5
try {
val event = damageEvent()
event.isCancelled shouldBe false
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
val expectedHorizontal = 0.4 * (1 - attribute.value)
vector.x shouldBe (expectedHorizontal plusOrMinus 0.0001)
vector.y shouldBe (0.4 plusOrMinus 0.0001)
removePending(victim.uniqueId)
} finally {
attribute.baseValue = originalBase
}
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/PlayerRegenIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModulePlayerRegen
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityRegainHealthEvent
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class PlayerRegenIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module = ModuleLoader.getModules().filterIsInstance().firstOrNull()
?: error("ModulePlayerRegen not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> T): T {
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
}).get()
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
ModuleLoader.toggleModules()
}
suspend fun withConfig(intervalMs: Long, amount: Int, exhaustion: Double, block: suspend () -> Unit) {
val oldInterval = ocm.config.getLong("old-player-regen.interval")
val oldAmount = ocm.config.getInt("old-player-regen.amount")
val oldExhaustion = ocm.config.getDouble("old-player-regen.exhaustion")
try {
runSync {
ocm.config.set("old-player-regen.interval", intervalMs)
ocm.config.set("old-player-regen.amount", amount)
ocm.config.set("old-player-regen.exhaustion", exhaustion)
module.reload()
ModuleLoader.toggleModules()
}
block()
} finally {
runSync {
ocm.config.set("old-player-regen.interval", oldInterval)
ocm.config.set("old-player-regen.amount", oldAmount)
ocm.config.set("old-player-regen.exhaustion", oldExhaustion)
module.reload()
ModuleLoader.toggleModules()
}
}
}
fun createRegainEvent(player: Player, reason: EntityRegainHealthEvent.RegainReason, amount: Double): EntityRegainHealthEvent {
val ctors = EntityRegainHealthEvent::class.java.constructors
for (ctor in ctors) {
val paramTypes = ctor.parameterTypes
val args = arrayOfNulls(paramTypes.size)
var ok = true
for (i in paramTypes.indices) {
val t = paramTypes[i]
args[i] = when {
org.bukkit.entity.Entity::class.java.isAssignableFrom(t) -> player
t == java.lang.Double.TYPE || t == Double::class.java -> amount
EntityRegainHealthEvent.RegainReason::class.java.isAssignableFrom(t) -> reason
else -> null
}
if (args[i] == null && t.isPrimitive) {
ok = false
break
}
}
if (!ok) continue
try {
@Suppress("UNCHECKED_CAST")
return ctor.newInstance(*args) as EntityRegainHealthEvent
} catch (_: Throwable) {
// Try next
}
}
error("No compatible EntityRegainHealthEvent constructor found for this server version")
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getWorld("world") ?: error("world not loaded")
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
fakePlayer = FakePlayer(testPlugin)
fakePlayer.spawn(location)
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
player.isOp = true
setModeset(player, "old")
}
}
afterSpec {
runSync { fakePlayer.removePlayer() }
}
beforeTest {
runSync {
setModeset(player, "old")
player.health = 20.0
player.exhaustion = 0f
player.saturation = 0f
// Ensure per-player state from previous tests does not leak (healTimes is keyed by UUID).
runCatching {
val names = listOf("lastHealTick", "healTimes")
val f = names.asSequence()
.mapNotNull { name -> runCatching { ModulePlayerRegen::class.java.getDeclaredField(name) }.getOrNull() }
.firstOrNull()
if (f != null) {
f.isAccessible = true
@Suppress("UNCHECKED_CAST")
(f.get(module) as? MutableMap)?.clear()
}
}
}
}
test("SATIATED regen is cancelled and replaced with configured heal + exhaustion") {
withConfig(intervalMs = 0, amount = 2, exhaustion = 3.0) {
runSync {
player.health = 10.0
player.exhaustion = 1.0f
}
val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
runSync {
module.onRegen(event)
event.isCancelled shouldBe true
player.health shouldBe (12.0 plusOrMinus 1e-9)
// Simulate vanilla modifying exhaustion despite the cancellation; OCM applies its own value next tick.
player.exhaustion = 2.0f
}
delay(2 * 50L)
runSync {
player.exhaustion.toDouble() shouldBe (4.0 plusOrMinus 0.0001) // previous(1.0) + config(3.0)
}
}
}
test("heal is skipped when within the configured interval") {
withConfig(intervalMs = 60_000, amount = 2, exhaustion = 3.0) {
runSync {
player.health = 10.0
player.exhaustion = 1.0f
}
val first = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
runSync {
module.onRegen(first)
player.health shouldBe (12.0 plusOrMinus 1e-9)
}
// Simulate immediate damage, then attempt to heal again within the interval.
runSync {
player.health = 10.0
player.exhaustion = 1.0f
}
val second = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
runSync {
module.onRegen(second)
second.isCancelled shouldBe true
player.health shouldBe (10.0 plusOrMinus 1e-9)
// Simulate vanilla exhaustion change; the module should restore to previous exhaustion next tick.
player.exhaustion = 3.5f
}
delay(2 * 50L)
runSync {
player.exhaustion.toDouble() shouldBe (1.0 plusOrMinus 0.0001)
}
}
}
test("non-SATIATED regain reasons are not modified") {
val nonSatiated = EntityRegainHealthEvent.RegainReason.values().firstOrNull {
it != EntityRegainHealthEvent.RegainReason.SATIATED
} ?: error("No non-SATIATED regain reason available")
withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {
runSync {
player.health = 10.0
player.exhaustion = 1.0f
}
val event = runSync { createRegainEvent(player, nonSatiated, 5.0) }
runSync {
module.onRegen(event)
event.isCancelled shouldBe false
player.health shouldBe (10.0 plusOrMinus 1e-9)
}
}
}
test("healing is clamped to max health") {
withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {
runSync { player.health = 19.5 }
val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
runSync {
module.onRegen(event)
player.health shouldBe (20.0 plusOrMinus 1e-9)
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SpigotFunctionChooserIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalKotest::class)
class SpigotFunctionChooserIntegrationTest :
StringSpec({
"compatibility failures choose fallback and stay cached" {
val compatibilityFailures =
listOf(
NoSuchMethodError("missing API method"),
NoClassDefFoundError("missing API class"),
AbstractMethodError("abstract API method"),
IncompatibleClassChangeError("binary incompatibility"),
CompatibilityUnsupportedOperationException("compatibility fallback approved"),
)
compatibilityFailures.forEach { throwable ->
val apiCalls = AtomicInteger(0)
val fallbackCalls = AtomicInteger(0)
val chooser =
SpigotFunctionChooser.apiCompatCall(
{ _, _ ->
apiCalls.incrementAndGet()
throw throwable
},
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
chooser.apply("target", Any()) shouldBe "fallback"
chooser.apply("target", Any()) shouldBe "fallback"
apiCalls.get() shouldBe 1
fallbackCalls.get() shouldBe 2
}
}
"ordinary logic failures are surfaced instead of choosing fallback" {
val fallbackCalls = AtomicInteger(0)
val chooser =
SpigotFunctionChooser.apiCompatCall(
{ _, _ -> throw IllegalStateException("business logic failure") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow {
chooser.apply("target", Any())
}
fallbackCalls.get() shouldBe 0
}
"null pointer failures are surfaced instead of choosing fallback" {
val fallbackCalls = AtomicInteger(0)
val chooser =
SpigotFunctionChooser.apiCompatCall(
{ _, _ -> throw NullPointerException("unexpected null") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow {
chooser.apply("target", Any())
}
fallbackCalls.get() shouldBe 0
}
"generic unsupported operation with incompatible wording is surfaced" {
val fallbackCalls = AtomicInteger(0)
val chooser =
SpigotFunctionChooser.apiCompatCall(
{ _, _ -> throw UnsupportedOperationException("incompatible state for this action") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow {
chooser.apply("target", Any())
}
fallbackCalls.get() shouldBe 0
}
})
private class CompatibilityUnsupportedOperationException(
message: String,
) : UnsupportedOperationException(message)
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SwordBlockingIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.StringSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.BlockFace
import org.bukkit.entity.Entity
import org.bukkit.entity.EntityType
import org.bukkit.entity.Item
import org.bukkit.entity.Player
import org.bukkit.event.block.Action
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerInteractAtEntityEvent
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.util.Vector
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class SwordBlockingIntegrationTest :
StringSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module =
ModuleLoader
.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleSwordBlocking not registered")
extension(MainThreadDispatcherExtension(plugin))
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
plugin,
Callable {
action()
},
).get()
}
fun preparePlayer() {
println("Preparing player")
val world = Bukkit.getServer().getWorld("world")
val location = Location(world, 0.0, 100.0, 0.0)
fakePlayer = FakePlayer(plugin)
fakePlayer.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
}
beforeSpec {
Bukkit.getScheduler().runTask(
plugin,
Runnable {
plugin.logger.info("Running before all")
preparePlayer()
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0 // remove spawn invulnerability
player.isInvulnerable = false
},
)
}
fun rightClickWithMainHand() =
runSync {
val event =
PlayerInteractEvent(
player,
Action.RIGHT_CLICK_AIR,
player.inventory.itemInMainHand,
null,
BlockFace.SELF,
EquipmentSlot.HAND,
)
Bukkit.getPluginManager().callEvent(event)
}
fun rightClickEntity(
target: Entity,
hand: EquipmentSlot,
) = runSync {
Bukkit.getPluginManager().callEvent(PlayerInteractEntityEvent(player, target, hand))
}
fun rightClickEntityAt(
target: Entity,
hand: EquipmentSlot,
) = runSync {
Bukkit.getPluginManager().callEvent(PlayerInteractAtEntityEvent(player, target, Vector(0.0, 1.0, 0.0), hand))
}
fun spawnEntityTarget(): Entity =
runSync {
player.world.spawnEntity(player.location.clone().add(1.0, 0.0, 0.0), EntityType.VILLAGER)
}
fun forceRestoreViaHotbarChange() =
runSync {
val previous = player.inventory.heldItemSlot
val next = (previous + 1) % 9
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, previous, next))
player.inventory.heldItemSlot = previous
}
fun paperConsumablePathAvailable(): Boolean =
try {
Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
val supportedField = ModuleSwordBlocking::class.java.getDeclaredField("paperSupported")
supportedField.isAccessible = true
val adapterField = ModuleSwordBlocking::class.java.getDeclaredField("paperAdapter")
adapterField.isAccessible = true
supportedField.getBoolean(module) && adapterField.get(module) != null
} catch (_: Throwable) {
false
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
suspend fun TestScope.withPaperAnimationEnabled(
enabled: Boolean,
block: suspend TestScope.() -> Unit,
) {
val original = runSync { ocm.config.get("sword-blocking.paper-animation") }
runSync {
ocm.config.set("sword-blocking.paper-animation", enabled)
ocm.saveConfig()
Config.reload()
}
try {
block()
} finally {
runSync {
ocm.config.set("sword-blocking.paper-animation", original)
ocm.saveConfig()
Config.reload()
}
}
}
suspend fun TestScope.withUsePermission(
required: Boolean,
block: suspend TestScope.() -> Unit,
) {
val original = runSync { ocm.config.getBoolean("sword-blocking.use-permission") }
runSync {
ocm.config.set("sword-blocking.use-permission", required)
module.reload()
ModuleLoader.toggleModules()
}
try {
block()
} finally {
runSync {
ocm.config.set("sword-blocking.use-permission", original)
module.reload()
ModuleLoader.toggleModules()
}
}
}
beforeTest {
runSync {
player.inventory.clear()
player.noDamageTicks = 0
player.maximumNoDamageTicks = 20
player.isInvulnerable = false
}
}
afterTest {
forceRestoreViaHotbarChange()
runSync { player.inventory.clear() }
}
afterSpec {
plugin.logger.info("Running after all")
Bukkit.getScheduler().runTask(
plugin,
Runnable {
fakePlayer.removePlayer()
},
)
}
"adds blocking when right-clicking with a sword (shield on legacy, consumable on Paper)" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
// Legacy path: module injects a shield (actual "blocking" state is client-driven).
// Paper path: offhand remains intact and a consumable-based use animation can surface as "hand raised".
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
// Legacy path injects shield; paper path keeps offhand intact
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
}
"paper-animation config false forces legacy shield fallback on Paper" {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
} else {
withPaperAnimationEnabled(enabled = false) {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
}
}
}
"does not start blocking without a sword in the main hand" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.STICK))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
}
"starts blocking on main-hand entity right-click (shield on legacy, consumable on Paper)" {
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickEntity(target, EquipmentSlot.HAND)
delayTicks(1)
runSync {
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
} finally {
runSync { target.remove() }
}
}
"does not start blocking on offhand entity right-click" {
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickEntity(target, EquipmentSlot.OFF_HAND)
delayTicks(1)
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
} finally {
runSync { target.remove() }
}
}
"entity interact plus interact-at should not duplicate side effects" {
val originalOffhand = ItemStack(Material.APPLE)
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(originalOffhand.clone())
}
rightClickEntity(target, EquipmentSlot.HAND)
rightClickEntityAt(target, EquipmentSlot.HAND)
delayTicks(1)
runSync {
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
forceRestoreViaHotbarChange()
}
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
}
} finally {
runSync { target.remove() }
}
}
"restores the previous offhand item after a hotbar change (or leaves untouched on Paper path)" {
val originalOffhand = ItemStack(Material.APPLE)
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(originalOffhand.clone())
}
rightClickWithMainHand()
runSync {
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
forceRestoreViaHotbarChange()
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
} else {
// Paper path: offhand never changed
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
}
}
}
"cancels dropping the temporary shield and restores the stored item (legacy path only)" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
val dropped: Item = runSync { player.world.dropItem(player.location, ItemStack(Material.SHIELD)) }
runSync {
Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, dropped))
}
runSync {
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
player.inventory.itemInOffHand.type shouldBe Material.AIR
player.isBlocking shouldBe false
} else {
// Paper path: no injected shield; ensure we did not cancel normal state
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
}
runSync { dropped.remove() }
}
"respects permission requirement when enabled" {
withUsePermission(required = true) {
runSync {
player.isOp = false
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
runSync { player.addAttachment(plugin, "oldcombatmechanics.swordblock", true) }
rightClickWithMainHand()
delayTicks(1)
runSync {
// Legacy path injects a shield and sets isBlocking; Paper path keeps offhand intact and uses a
// consumable-based use animation which can surface as "hand raised".
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
}
}
"does not replace an existing real shield in offhand" {
val namedShield =
ItemStack(Material.SHIELD).apply {
val meta = itemMeta
meta?.setDisplayName("Real Shield")
itemMeta = meta
}
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(namedShield)
}
rightClickWithMainHand()
runSync {
val meta = player.inventory.itemInOffHand.itemMeta
meta?.displayName shouldBe "Real Shield"
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/SwordSweepIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweep
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class SwordSweepIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance()
.firstOrNull() ?: error("ModuleSwordSweep not registered")
lateinit var attacker: Player
lateinit var victim: Player
lateinit var fakeAttacker: FakePlayer
lateinit var fakeVictim: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.0, 100.0, 0.0)
fakeAttacker = FakePlayer(testPlugin)
fakeVictim = FakePlayer(testPlugin)
fakeAttacker.spawn(attackerLocation)
fakeVictim.spawn(victimLocation)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
setModeset(attacker, "old")
setModeset(victim, "old")
module.reload()
}
}
afterSpec {
runSync {
fakeAttacker.removePlayer()
fakeVictim.removePlayer()
}
}
beforeTest {
runSync {
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
setModeset(attacker, "old")
setModeset(victim, "old")
module.reload()
}
}
context("Sweep attack cancellation") {
test("sweep attack is cancelled when enabled") {
val sweepCause = EntityDamageEvent.DamageCause.values()
.firstOrNull { it.name == "ENTITY_SWEEP_ATTACK" }
if (sweepCause != null) {
val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)
Bukkit.getPluginManager().callEvent(event)
event.isCancelled shouldBe true
} else {
// Legacy (1.9): simulate sweep detection fallback (no dedicated cause)
val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)
?: 1.0f).toDouble()
val sweepDamage = 1.0 // matches ModuleSwordSweep fallback for level 0
// First, register the attacker location so the module can recognise the next hit as sweep
val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)
module.onEntityDamaged(priming)
val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)
module.onEntityDamaged(sweepEvent)
sweepEvent.isCancelled shouldBe true
}
}
test("non-sweep attack is not cancelled") {
val event = EntityDamageByEntityEvent(
attacker,
victim,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
1.0
)
Bukkit.getPluginManager().callEvent(event)
event.isCancelled shouldBe false
}
test("disabled module does not cancel sweep") {
setModeset(attacker, "new")
val sweepCause = EntityDamageEvent.DamageCause.values()
.firstOrNull { it.name == "ENTITY_SWEEP_ATTACK" }
if (sweepCause != null) {
val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)
module.onEntityDamaged(event)
event.isCancelled shouldBe false
} else {
val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)
?: 1.0f).toDouble()
val sweepDamage = 1.0
val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)
module.onEntityDamaged(priming)
val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)
module.onEntityDamaged(sweepEvent)
sweepEvent.isCancelled shouldBe false
}
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/Tally.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
class Tally {
var passed: Int = 0
private set
var failed: Int = 0
private set
fun passed() {
passed++
}
fun failed() {
failed++
}
val total: Int
get() = passed + failed
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/TestResultWriter.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.logging.Level
object TestResultWriter {
@JvmStatic
fun writeAndShutdown(plugin: JavaPlugin, success: Boolean, error: Throwable? = null) {
try {
val resultFile = File(plugin.dataFolder, "test-results.txt")
resultFile.parentFile.mkdirs()
resultFile.writeText(if (success) "PASS" else "FAIL")
plugin.logger.info("Test result written to ${resultFile.absolutePath}")
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "Failed to write test results file.", e)
}
if (error != null) {
plugin.logger.log(Level.SEVERE, "Integration tests failed.", error)
}
Bukkit.shutdown()
}
@JvmStatic
fun writeFailureSummary(plugin: JavaPlugin, lines: List) {
try {
val file = File(plugin.dataFolder, "test-failures.txt")
file.parentFile.mkdirs()
file.writeText(lines.joinToString(separator = "\n", postfix = if (lines.isEmpty()) "" else "\n"))
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "Failed to write test failures file.", e)
}
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/TesterUtils.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import kernitus.plugin.OldCombatMechanics.utilities.Messenger.send
import org.bukkit.command.CommandSender
import org.bukkit.entity.LivingEntity
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
object TesterUtils {
/**
* Checks whether the two values are equal, prints the result and updates the tally
*
* @param a The expected value
* @param b The actual value
* @param tally The tally to update the result of the test with
* @param testName The name of the test being run
* @param senders The command senders to message with the result of the test
*/
fun assertEquals(a: Float, b: Float, tally: Tally, testName: String, vararg senders: CommandSender) {
// Due to cooldown effects, numbers can be very close (e.g. 1.0000000149011612 == 1.0)
// These are equivalent when using floats, which is what the server is using anyway
if (a == b) {
tally.passed()
for (sender in senders) send(
sender,
"&aPASSED &f$testName [E: $a / A: $b]"
)
} else {
tally.failed()
for (sender in senders) send(
sender,
"&cFAILED &f$testName [E: $a / A: $b]"
)
}
}
/**
* Cross-version accessor for a specific potion effect. Pre-1.12 servers lack
* LivingEntity#getPotionEffect, so we fall back to scanning active effects.
*/
fun LivingEntity.getPotionEffectCompat(type: PotionEffectType): PotionEffect? {
// Prefer reflection to avoid linkage errors on legacy servers.
val method = javaClass.methods.firstOrNull { m ->
m.name == "getPotionEffect" &&
m.parameterTypes.size == 1 &&
m.parameterTypes[0] == PotionEffectType::class.java
}
return runCatching { method?.invoke(this, type) as PotionEffect? }.getOrNull()
?: activePotionEffects.firstOrNull { it.type == type }
}
}
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/ToolDamageTooltipIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.ChatColor
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerSwapHandItemsEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ToolDamageTooltipIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
fun runSyncAndGet(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
val lorePrefix = "OCM Damage:"
fun stripColour(line: String): String = ChatColor.stripColor(line) ?: line
fun findOcmLines(item: ItemStack): List {
val lore = item.itemMeta?.lore ?: emptyList()
return lore.filter { stripColour(it).startsWith(lorePrefix) }
}
fun parseFirstDamage(item: ItemStack): Double? {
val line = findOcmLines(item).firstOrNull() ?: return null
val stripped = stripColour(line)
val match = Regex("(-?\\d+(?:\\.\\d+)?)").find(stripped) ?: return null
return match.value.toDoubleOrNull()
}
fun setLore(
item: ItemStack,
lines: List?,
) {
val meta = item.itemMeta ?: return
meta.lore = lines
item.itemMeta = meta
}
data class SpawnedPlayer(
val fake: FakePlayer,
val player: Player,
)
fun spawnFake(location: Location): SpawnedPlayer {
lateinit var fake: FakePlayer
lateinit var player: Player
runSync {
fake = FakePlayer(testPlugin)
fake.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.inventory.clear()
player.isInvulnerable = false
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val data =
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.getPlayerData(player.uniqueId)
data.setModesetForWorld(player.world.uid, "old")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.setPlayerData(player.uniqueId, data)
}
return SpawnedPlayer(fake, player)
}
fun cleanup(vararg players: SpawnedPlayer) {
runSync { players.forEach { it.fake.removePlayer() } }
}
suspend fun TestScope.withConfig(
weaponMaterialKey: String,
weaponDamage: Double,
block: suspend TestScope.() -> Unit,
) {
val disabledModules = ocm.config.getStringList("disabled_modules")
val modesetsSection = ocm.config.getConfigurationSection("modesets") ?: error("Missing 'modesets' section in config")
val modesetSnapshot =
modesetsSection.getKeys(false).associateWith { key ->
ocm.config.getStringList("modesets.$key")
}
val damagesSnapshot =
ocm.config
.getConfigurationSection("old-tool-damage.damages")
?.getValues(false)
?: emptyMap()
val tooltipEnabledSnapshot = ocm.config.get("old-tool-damage.tooltip.enabled")
val tooltipPrefixSnapshot = ocm.config.get("old-tool-damage.tooltip.prefix")
fun reloadAll() {
ocm.saveConfig()
Config.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
}
try {
ocm.config.set("old-tool-damage.damages.$weaponMaterialKey", weaponDamage)
ocm.config.set("old-tool-damage.tooltip.enabled", true)
ocm.config.set("old-tool-damage.tooltip.prefix", lorePrefix)
ocm.config.set("disabled_modules", disabledModules.filterNot { it == "old-tool-damage" })
val oldModeset = ocm.config.getStringList("modesets.old").toMutableList()
if (!oldModeset.contains("old-tool-damage")) {
oldModeset.add("old-tool-damage")
}
ocm.config.set("modesets.old", oldModeset)
reloadAll()
block()
} finally {
ocm.config.set("disabled_modules", disabledModules)
modesetSnapshot.forEach { (key, list) -> ocm.config.set("modesets.$key", list) }
ocm.config.set("old-tool-damage.damages", null)
damagesSnapshot.forEach { (k, v) -> ocm.config.set("old-tool-damage.damages.$k", v) }
ocm.config.set("old-tool-damage.tooltip.enabled", tooltipEnabledSnapshot)
ocm.config.set("old-tool-damage.tooltip.prefix", tooltipPrefixSnapshot)
reloadAll()
}
}
fun fireJoin(player: Player) {
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, "test"))
}
fun switchHotbar(
player: Player,
from: Int,
to: Int,
) {
player.inventory.heldItemSlot = to
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))
}
test("adds a tooltip lore line for configured vanilla weapon damage") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItem(0, sword)
p.player.inventory.setItem(1, ItemStack(Material.STICK))
p.player.inventory.heldItemSlot = 1
}
runSync { switchHotbar(p.player, from = 1, to = 0) }
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val loreLines = findOcmLines(held)
loreLines.size shouldBe 1
parseFirstDamage(held) shouldBe (7.0 plusOrMinus 0.01)
cleanup(p)
}
}
test("does not duplicate the tooltip lore line when applied repeatedly") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItemInMainHand(sword)
}
runSync {
fireJoin(p.player)
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val loreLines = findOcmLines(held)
loreLines.size shouldBe 1
cleanup(p)
}
}
test("preserves existing lore when adding the tooltip line") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
setLore(sword, listOf("OtherPlugin: Example"))
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val lore = held.itemMeta?.lore ?: emptyList()
lore.any { stripColour(it) == "OtherPlugin: Example" } shouldBe true
findOcmLines(held).size shouldBe 1
cleanup(p)
}
}
test("updates tooltip damage after config reload") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
}
runSyncAndGet { parseFirstDamage(p.player.inventory.itemInMainHand) } shouldBe (7.0 plusOrMinus 0.01)
runSync {
ocm.config.set("old-tool-damage.damages.DIAMOND_SWORD", 9.0)
ocm.saveConfig()
Config.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
findOcmLines(held).size shouldBe 1
parseFirstDamage(held) shouldBe (9.0 plusOrMinus 0.01)
cleanup(p)
}
}
test("cleans the tooltip lore line when the module is disabled") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItem(0, sword)
p.player.inventory.setItem(1, ItemStack(Material.STICK))
p.player.inventory.heldItemSlot = 1
switchHotbar(p.player, from = 1, to = 0)
}
runSyncAndGet {
val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)
findOcmLines(slot0).size
} shouldBe 1
runSync {
val data =
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.getPlayerData(p.player.uniqueId)
data.setModesetForWorld(p.player.world.uid, "new")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.setPlayerData(p.player.uniqueId, data)
switchHotbar(p.player, from = 0, to = 1) // should clean the old hand
}
delayTicks(1)
runSyncAndGet {
val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)
findOcmLines(slot0).size
} shouldBe 0
cleanup(p)
}
}
test("does not add a tooltip lore line for non-weapons") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val stick = ItemStack(Material.STICK)
runSync {
p.player.inventory.setItemInMainHand(stick)
fireJoin(p.player)
}
findOcmLines(stick).size shouldBe 0
cleanup(p)
}
}
test("swap hand items applies tooltip to new main hand and keeps offhand clean") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
val stick = ItemStack(Material.STICK)
runSync {
p.player.inventory.setItemInMainHand(stick)
p.player.inventory.setItemInOffHand(sword)
val swap = PlayerSwapHandItemsEvent(p.player, stick, sword)
Bukkit.getPluginManager().callEvent(swap)
}
findOcmLines(sword).size shouldBe 1
findOcmLines(stick).size shouldBe 0
cleanup(p)
}
}
test("swap hand finalisation keeps tooltip only on new main-hand weapon") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
runSync {
p.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
p.player.inventory.setItemInOffHand(ItemStack(Material.DIAMOND_SWORD))
val swap =
PlayerSwapHandItemsEvent(
p.player,
p.player.inventory.itemInMainHand,
p.player.inventory.itemInOffHand,
)
Bukkit.getPluginManager().callEvent(swap)
val newMainHand = swap.offHandItem?.clone() ?: ItemStack(Material.AIR)
val newOffHand = swap.mainHandItem?.clone() ?: ItemStack(Material.AIR)
p.player.inventory.setItemInMainHand(newMainHand)
p.player.inventory.setItemInOffHand(newOffHand)
}
val mainHand =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val offHand =
runSyncAndGet {
p.player.inventory.itemInOffHand
.clone()
}
mainHand.type shouldBe Material.DIAMOND_SWORD
offHand.type shouldBe Material.STICK
findOcmLines(mainHand).size shouldBe 1
findOcmLines(offHand).size shouldBe 0
cleanup(p)
}
}
test("plays nicely with a lore-rewriting plugin (other lore preserved, no duplication)") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
val otherPlugin =
object : Listener {
@EventHandler(priority = EventPriority.LOWEST)
fun onJoin(event: PlayerJoinEvent) {
if (event.player != p.player) return
val item = event.player.inventory.itemInMainHand
if (item.type != Material.DIAMOND_SWORD) return
setLore(item, listOf("OtherPlugin: Rewritten"))
}
@EventHandler(priority = EventPriority.LOWEST)
fun onHeld(event: PlayerItemHeldEvent) {
if (event.player != p.player) return
val item = event.player.inventory.itemInMainHand
if (item.type != Material.DIAMOND_SWORD) return
setLore(item, listOf("OtherPlugin: Rewritten"))
}
}
runSync {
Bukkit.getPluginManager().registerEvents(otherPlugin, testPlugin)
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
fireJoin(p.player)
HandlerList.unregisterAll(otherPlugin)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val lore = held.itemMeta?.lore ?: emptyList()
lore.any { stripColour(it) == "OtherPlugin: Rewritten" } shouldBe true
findOcmLines(held).size shouldBe 1
cleanup(p)
}
}
})
================================================
FILE: src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/WeaponDurabilityIntegrationTest.kt
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerItemDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.concurrent.Callable
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalKotest::class)
class WeaponDurabilityIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
fun scoreAttackMethodLocal(method: java.lang.reflect.Method): Int {
var score = 0
val name = method.name
val param = method.parameterTypes[0]
val declaring = method.declaringClass.simpleName
if (name == "attack") score += 100
if (name == "a") score += 80
if (param.simpleName == "Entity") score += 40
if (param.simpleName.contains("Entity")) score += 10
if (method.returnType == Void.TYPE) score += 10
if (method.returnType == java.lang.Boolean.TYPE) score += 8
if (declaring.contains("EntityHuman")) score += 25
if (declaring.contains("EntityPlayer")) score += 20
return score
}
fun methodSignatureLocal(method: java.lang.reflect.Method): String {
val params = method.parameterTypes.joinToString(",") { it.name }
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
}
fun collectAllMethods(start: Class<*>): List {
val methods = LinkedHashMap()
var current: Class<*>? = start
while (current != null) {
current.declaredMethods.forEach { method ->
methods.putIfAbsent(methodSignatureLocal(method), method)
}
current = current.superclass
}
start.methods.forEach { method ->
methods.putIfAbsent(methodSignatureLocal(method), method)
}
return methods.values.toList()
}
fun attackNms(
attacker: Player,
target: LivingEntity,
) {
runCatching {
attacker.attack(target)
return
}
val attackerHandle =
attacker.javaClass.methods
.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
}?.invoke(attacker) ?: error("Failed to resolve CraftPlayer#getHandle for attacker")
val targetHandle =
target.javaClass.methods
.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
}?.invoke(target) ?: error("Failed to resolve CraftPlayer#getHandle for target")
val attackerHandleClass = attackerHandle.javaClass
val targetHandleClass = targetHandle.javaClass
runCatching {
val managerField =
attackerHandleClass.declaredFields.firstOrNull { field ->
field.type.simpleName.contains("GameMode") || field.type.simpleName.contains("InteractManager")
}
if (managerField != null) {
managerField.isAccessible = true
val manager = managerField.get(attackerHandle) ?: return@runCatching
val attackMethod =
manager.javaClass.methods.firstOrNull { method ->
(method.name == "attack" || method.name == "a") &&
method.parameterCount == 1 &&
method.parameterTypes[0].isAssignableFrom(targetHandleClass)
}
if (attackMethod != null) {
attackMethod.isAccessible = true
attackMethod.invoke(manager, targetHandle)
return
}
}
}
val candidates =
listOfNotNull(
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(
attackerHandleClass,
"attack",
targetHandleClass,
),
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(
attackerHandleClass,
"a",
targetHandleClass,
),
).ifEmpty {
collectAllMethods(attackerHandleClass)
.asSequence()
.filter { it.parameterCount == 1 }
.filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
.map { method -> method to scoreAttackMethodLocal(method) }
.sortedByDescending { it.second }
.map { it.first }
.toList()
}
candidates.forEach { it.isAccessible = true }
for (method in candidates) {
try {
val result = method.invoke(attackerHandle, targetHandle)
if (result is Boolean && !result) continue
return
} catch (ignored: Exception) {
// try next
}
}
error("Failed to invoke NMS attack for FakePlayer attacker=${attackerHandleClass.name}")
}
fun resolveDebugFile(): File {
val versionTag = Bukkit.getBukkitVersion().replace(Regex("[^A-Za-z0-9_.-]"), "_")
val runDir = File(System.getProperty("user.dir"))
val repoRoot = runDir.parentFile?.parentFile ?: runDir
return File(repoRoot, "build/weapon-durability-debug-$versionTag.txt")
}
fun appendDebug(line: String) {
val file = resolveDebugFile()
file.parentFile?.mkdirs()
file.appendText(line + "\n")
}
fun describeNmsState(
attacker: Player,
victim: LivingEntity,
): String {
return runCatching {
val attackerHandle =
attacker.javaClass.methods
.firstOrNull { it.name == "getHandle" && it.parameterCount == 0 }
?.invoke(attacker) ?: return@runCatching "noAttackerHandle"
val victimHandle =
victim.javaClass.methods
.firstOrNull { it.name == "getHandle" && it.parameterCount == 0 }
?.invoke(victim) ?: return@runCatching "noVictimHandle"
fun flag(
handle: Any,
name: String,
): String? {
val method = handle.javaClass.methods.firstOrNull { it.name == name && it.parameterCount == 0 }
val value = method?.invoke(handle)
return value?.toString()
}
val attackerAlive = flag(attackerHandle, "isAlive")
val victimAlive = flag(victimHandle, "isAlive")
val victimRemoved = flag(victimHandle, "isRemoved")
"attackerAlive=$attackerAlive victimAlive=$victimAlive victimRemoved=$victimRemoved"
}.getOrElse { "nmsErr=${it::class.java.simpleName}" }
}
fun setItemDamage(
item: ItemStack,
damage: Int,
) {
val meta = item.itemMeta
if (meta != null) {
try {
val damageableClass = Class.forName("org.bukkit.inventory.meta.Damageable")
if (damageableClass.isInstance(meta)) {
val setDamage = damageableClass.getMethod("setDamage", Int::class.javaPrimitiveType)
setDamage.invoke(meta, damage)
item.itemMeta = meta
return
}
} catch (ignored: ClassNotFoundException) {
// Legacy server, fall back to durability.
}
}
@Suppress("DEPRECATION")
item.durability = damage.toShort()
}
fun getItemDamage(item: ItemStack): Int {
val meta = item.itemMeta
if (meta != null) {
try {
val damageableClass = Class.forName("org.bukkit.inventory.meta.Damageable")
if (damageableClass.isInstance(meta)) {
val getDamage = damageableClass.getMethod("getDamage")
return (getDamage.invoke(meta) as Number).toInt()
}
} catch (ignored: ClassNotFoundException) {
// Legacy server, fall back to durability.
}
}
@Suppress("DEPRECATION")
return item.durability.toInt()
}
fun setOldModeset(player: Player) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
}
suspend fun withAttackerAndVictim(block: suspend (attacker: Player, victim: LivingEntity) -> Unit) {
lateinit var attacker: Player
lateinit var victim: LivingEntity
val attackerFake = FakePlayer(testPlugin)
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
attackerFake.spawn(Location(world, 0.0, 100.0, 0.0))
val zombie = world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Zombie::class.java)
attacker = checkNotNull(Bukkit.getPlayer(attackerFake.uuid))
victim = zombie
setOldModeset(attacker)
attacker.inventory.clear()
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
attacker.isInvulnerable = false
victim.isInvulnerable = false
victim.health = victim.maxHealth
victim.noDamageTicks = 0
}
try {
repeat(40) {
if (attacker.isOnline && attacker.isValid && victim.isValid && !victim.isDead) {
if (attacker.world.players.contains(attacker) && attacker.world.entities.any { it.uniqueId == victim.uniqueId }) {
return@repeat
}
}
delayTicks(1)
}
block(attacker, victim)
} finally {
runSync {
attackerFake.removePlayer()
if (victim.isValid) victim.remove()
}
}
}
test("weapon durability only changes with successful hits during invulnerability") {
withAttackerAndVictim { attacker, victim ->
appendDebug("invuln:start")
try {
val weapon = ItemStack(Material.IRON_SWORD)
setItemDamage(weapon, 0)
runSync {
attacker.inventory.setItemInMainHand(weapon)
victim.maximumNoDamageTicks = 100
victim.noDamageTicks = 0
attacker.gameMode = org.bukkit.GameMode.SURVIVAL
val direction = victim.location.toVector().subtract(attacker.location.toVector())
attacker.teleport(attacker.location.setDirection(direction))
}
delayTicks(5)
appendDebug(
"invuln:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} " +
"victimDead=${victim.isDead} " +
"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} " +
"worldPvp=${victim.world.pvp} " +
"nms=${describeNmsState(attacker, victim)}",
)
val hitCount = AtomicInteger(0)
val totalHitCount = AtomicInteger(0)
val cancelledHitCount = AtomicInteger(0)
val victimEventCount = AtomicInteger(0)
val anyDamageEventCount = AtomicInteger(0)
val allDamageEventCount = AtomicInteger(0)
val itemDamageCount = AtomicInteger(0)
val listener =
object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId) {
victimEventCount.incrementAndGet()
if (event.damager == attacker) {
totalHitCount.incrementAndGet()
if (event.isCancelled) {
cancelledHitCount.incrementAndGet()
} else {
hitCount.incrementAndGet()
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onAnyDamage(event: EntityDamageEvent) {
allDamageEventCount.incrementAndGet()
if (event.entity.uniqueId == victim.uniqueId) {
anyDamageEventCount.incrementAndGet()
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onItemDamage(event: PlayerItemDamageEvent) {
if (event.player == attacker && event.item.type == Material.IRON_SWORD) {
itemDamageCount.addAndGet(event.damage)
}
}
}
runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }
try {
runSync {
Bukkit.getPluginManager().callEvent(
EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),
)
}
delayTicks(1)
appendDebug("invuln:afterManualEvent allDamageEvents=${allDamageEventCount.get()}")
runSync { attackNms(attacker, victim) }
delayTicks(1)
repeat(10) {
runSync { attackNms(attacker, victim) }
delayTicks(1)
}
delayTicks(2)
} finally {
runSync { HandlerList.unregisterAll(listener) }
}
val hits = hitCount.get()
val totalHits = totalHitCount.get()
val cancelledHits = cancelledHitCount.get()
val damageEvents = itemDamageCount.get()
val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)
if (totalHits == 0) {
val beforeHealth = victim.health
runSync { victim.damage(1.0, attacker) }
delayTicks(1)
appendDebug(
"invuln:afterDamage totalHits=${totalHitCount.get()} " +
"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}",
)
}
appendDebug(
"invuln:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits " +
"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} " +
"allDamageEvents=${allDamageEventCount.get()} " +
"itemDamageEvents=$damageEvents itemDamage=$actualDamage",
)
if (hits <= 0) {
// Retry a few swings in case legacy fake player validity lagged.
repeat(5) {
runSync {
victim.noDamageTicks = 0
attackNms(attacker, victim)
}
delayTicks(1)
}
}
val finalHits = hitCount.get()
val finalDamageEvents = itemDamageCount.get()
val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)
val isModern =
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
.versionIsNewerOrEqualTo(1, 12, 0)
if (!isModern) {
// Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.
return@withAttackerAndVictim
}
if (finalHits <= 0) {
error("Expected at least one successful hit, got $finalHits")
}
if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {
error(
"Durability changed per click, not per hit: hits=$finalHits " +
"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage",
)
}
} catch (e: Throwable) {
appendDebug("invuln:error=${e::class.java.simpleName}: ${e.message}")
throw e
}
}
}
test("weapon durability increments on hits after invulnerability expires") {
withAttackerAndVictim { attacker, victim ->
appendDebug("expire:start")
try {
val weapon = ItemStack(Material.IRON_SWORD)
setItemDamage(weapon, 0)
runSync {
attacker.inventory.setItemInMainHand(weapon)
victim.maximumNoDamageTicks = 10
victim.noDamageTicks = 0
attacker.gameMode = org.bukkit.GameMode.SURVIVAL
val direction = victim.location.toVector().subtract(attacker.location.toVector())
attacker.teleport(attacker.location.setDirection(direction))
}
delayTicks(5)
appendDebug(
"expire:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} " +
"victimDead=${victim.isDead} " +
"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} " +
"worldPvp=${victim.world.pvp} " +
"nms=${describeNmsState(attacker, victim)}",
)
val hitCount = AtomicInteger(0)
val totalHitCount = AtomicInteger(0)
val cancelledHitCount = AtomicInteger(0)
val victimEventCount = AtomicInteger(0)
val anyDamageEventCount = AtomicInteger(0)
val allDamageEventCount = AtomicInteger(0)
val itemDamageCount = AtomicInteger(0)
val listener =
object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId) {
victimEventCount.incrementAndGet()
if (event.damager == attacker) {
totalHitCount.incrementAndGet()
if (event.isCancelled) {
cancelledHitCount.incrementAndGet()
} else {
hitCount.incrementAndGet()
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onAnyDamage(event: EntityDamageEvent) {
allDamageEventCount.incrementAndGet()
if (event.entity.uniqueId == victim.uniqueId) {
anyDamageEventCount.incrementAndGet()
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onItemDamage(event: PlayerItemDamageEvent) {
if (event.player == attacker && event.item.type == Material.IRON_SWORD) {
itemDamageCount.addAndGet(event.damage)
}
}
}
runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }
try {
runSync {
Bukkit.getPluginManager().callEvent(
EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),
)
}
delayTicks(1)
appendDebug("expire:afterManualEvent allDamageEvents=${allDamageEventCount.get()}")
runSync { attackNms(attacker, victim) }
delayTicks(12)
runSync { attackNms(attacker, victim) }
delayTicks(2)
} finally {
runSync { HandlerList.unregisterAll(listener) }
}
val hits = hitCount.get()
val totalHits = totalHitCount.get()
val cancelledHits = cancelledHitCount.get()
val damageEvents = itemDamageCount.get()
val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)
if (totalHits == 0) {
val beforeHealth = victim.health
runSync { victim.damage(1.0, attacker) }
delayTicks(1)
appendDebug(
"expire:afterDamage totalHits=${totalHitCount.get()} " +
"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}",
)
}
appendDebug(
"expire:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits " +
"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} " +
"allDamageEvents=${allDamageEventCount.get()} " +
"itemDamageEvents=$damageEvents itemDamage=$actualDamage",
)
if (hits < 2) {
repeat(4) {
runSync { attackNms(attacker, victim) }
delayTicks(2)
}
}
val finalHits = hitCount.get()
val finalDamageEvents = itemDamageCount.get()
val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)
val isModern =
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
.versionIsNewerOrEqualTo(1, 12, 0)
if (!isModern) {
// Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.
return@withAttackerAndVictim
}
val expectedHits = 2
if (finalHits < expectedHits) {
error("Expected at least $expectedHits hits after invulnerability expiry, got $finalHits")
}
if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {
error(
"Durability did not match hits: hits=$finalHits " +
"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage",
)
}
} catch (e: Throwable) {
appendDebug("expire:error=${e::class.java.simpleName}: ${e.message}")
throw e
}
}
}
})
================================================
FILE: src/integrationTest/resources/plugin.yml
================================================
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
main: kernitus.plugin.OldCombatMechanics.OCMTestMain
name: OldCombatMechanicsTest
version: 0.0.1
authors: [ kernitus, Rayzr522 ]
description: Reverts to pre-1.9 combat mechanics
website: https://github.com/kernitus/BukkitOldCombatMechanics
load: POSTWORLD
softdepend: [ OldCombatMechanics ]
api-version: 1.13
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/ModuleLoader.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.module.OCMModule;
import kernitus.plugin.OldCombatMechanics.utilities.EventRegistry;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import java.util.ArrayList;
import java.util.List;
public class ModuleLoader {
private static EventRegistry eventRegistry;
private static final List modules = new ArrayList<>();
public static void initialise(OCMMain plugin) {
modules.clear();
ModuleLoader.eventRegistry = new EventRegistry(plugin);
}
public static void toggleModules() {
modules.forEach(module -> setState(module, module.isEnabled()));
}
private static void setState(OCMModule module, boolean state) {
if (state) {
if (eventRegistry.registerListener(module)) {
Messenger.debug("Enabled " + module.getClass().getSimpleName());
}
} else {
if (eventRegistry.unregisterListener(module)) {
Messenger.debug("Disabled " + module.getClass().getSimpleName());
}
}
}
public static void addModule(OCMModule module) {
modules.add(module);
}
public static List getModules() {
return modules;
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/OCMConfigHandler.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class OCMConfigHandler {
private final String CONFIG_NAME = "config.yml";
private final OCMMain plugin;
public OCMConfigHandler(OCMMain instance) {
this.plugin = instance;
}
public void upgradeConfig() {
// Remove old backup file if present
final File backup = getFile("config-backup.yml");
if (backup.exists()) backup.delete();
// Keeping YAML comments not available in lower versions
if (Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||
Config.getConfig().getBoolean("force-below-1-18-1-config-upgrade", false)
) {
plugin.getLogger().warning("Config version does not match, upgrading old config");
final File configFile = getFile(CONFIG_NAME);
// Back up the old config file
if (!configFile.renameTo(backup)) {
plugin.getLogger().severe("Could not back up old config file. Aborting config upgrade.");
return;
}
// Save the new default config from the JAR to config.yml. This ensures all old keys are gone.
plugin.saveResource(CONFIG_NAME, true);
// Now, load the old values from the backup and the new config from the fresh file
final YamlConfiguration oldConfig = YamlConfiguration.loadConfiguration(backup);
final YamlConfiguration newConfig = YamlConfiguration.loadConfiguration(configFile);
// Copy user's values for keys that still exist
for (String key : newConfig.getKeys(true)) {
if (key.equals("config-version")) continue;
if (newConfig.isConfigurationSection(key)) continue;
if (oldConfig.contains(key) && !oldConfig.isConfigurationSection(key)) {
newConfig.set(key, oldConfig.get(key));
}
}
migrateModuleLists(oldConfig, newConfig);
// Save the final, merged config
try {
newConfig.save(configFile);
plugin.getLogger().info("Config has been updated. A backup of your old config is available at config-backup.yml");
} catch (IOException e) {
plugin.getLogger().severe("Failed to save upgraded config. It has been restored from backup.");
e.printStackTrace();
backup.renameTo(configFile); // Restore backup
}
} else {
plugin.getLogger().warning("Config version does not match, backing up old config and creating a new one");
// Change name of old config
final File configFile = getFile(CONFIG_NAME);
configFile.renameTo(backup);
}
// Save new version if none is present
setupConfigIfNotPresent();
}
/**
* Generates new config.yml file, if not present.
*/
public void setupConfigIfNotPresent() {
if (!doesConfigExist()) {
plugin.saveDefaultConfig();
plugin.getLogger().info("Config file generated");
}
}
private void migrateModuleLists(YamlConfiguration oldConfig, YamlConfiguration newConfig) {
final Set internalModules = new HashSet<>(Arrays.asList(
"modeset-listener",
"attack-cooldown-tracker",
"entity-damage-listener"
));
final Set optionalModules = new HashSet<>(Arrays.asList(
"disable-attack-sounds",
"disable-sword-sweep-particles"
));
final Set moduleNames = ModuleLoader.getModules().stream()
.map(module -> module.getConfigName().toLowerCase(Locale.ROOT))
.collect(Collectors.toCollection(LinkedHashSet::new));
final ConfigurationSection oldModesets = oldConfig.getConfigurationSection("modesets");
final Map> migratedModesets = new LinkedHashMap<>();
final Set modulesInModesets = new HashSet<>();
if (oldModesets != null) {
for (String modesetName : oldModesets.getKeys(false)) {
final List moduleList = oldModesets.getStringList(modesetName);
final List normalisedList = moduleList.stream()
.map(name -> name.toLowerCase(Locale.ROOT))
.collect(Collectors.toList());
normalisedList.removeIf(internalModules::contains);
migratedModesets.put(modesetName, normalisedList);
modulesInModesets.addAll(normalisedList);
}
}
moduleNames.addAll(optionalModules);
if (moduleNames.isEmpty()) {
for (String key : oldConfig.getKeys(true)) {
if (!key.endsWith(".enabled")) {
continue;
}
final String moduleName = key.substring(0, key.length() - ".enabled".length())
.toLowerCase(Locale.ROOT);
if (internalModules.contains(moduleName)) {
continue;
}
moduleNames.add(moduleName);
}
moduleNames.addAll(modulesInModesets);
}
moduleNames.removeAll(internalModules);
final List alwaysEnabled = new ArrayList<>();
final List disabledModules = new ArrayList<>();
for (String moduleName : moduleNames) {
final String enabledKey = moduleName + ".enabled";
if ("attack-range".equals(moduleName)) {
disabledModules.add(moduleName);
continue;
}
final boolean enabled = !oldConfig.contains(enabledKey) || oldConfig.getBoolean(enabledKey);
if (!enabled) {
disabledModules.add(moduleName);
continue;
}
if (!modulesInModesets.contains(moduleName)) {
alwaysEnabled.add(moduleName);
}
}
// Remove disabled modules from all modesets
if (!disabledModules.isEmpty()) {
for (Map.Entry> entry : migratedModesets.entrySet()) {
entry.getValue().removeIf(disabledModules::contains);
}
}
newConfig.set("always_enabled_modules", alwaysEnabled);
newConfig.set("disabled_modules", disabledModules);
if (!migratedModesets.isEmpty()) {
ConfigurationSection targetModesets = newConfig.getConfigurationSection("modesets");
if (targetModesets == null) {
targetModesets = newConfig.createSection("modesets");
}
// Remove old keys that are no longer present, without deleting the section (keeps placement)
for (String key : new ArrayList<>(targetModesets.getKeys(false))) {
if (!migratedModesets.containsKey(key)) {
targetModesets.set(key, null);
}
}
// Apply migrated entries
for (Map.Entry> entry : migratedModesets.entrySet()) {
targetModesets.set(entry.getKey(), entry.getValue());
}
}
final ConfigurationSection oldWorlds = oldConfig.getConfigurationSection("worlds");
if (oldWorlds != null) {
for (String worldName : oldWorlds.getKeys(false)) {
newConfig.set("worlds." + worldName, oldWorlds.getStringList(worldName));
}
}
}
public YamlConfiguration getConfig(String fileName) {
return YamlConfiguration.loadConfiguration(getFile(fileName));
}
public File getFile(String fileName) {
return new File(plugin.getDataFolder(), fileName.replace('/', File.separatorChar));
}
public boolean doesConfigExist() {
return getFile(CONFIG_NAME).exists();
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/OCMMain.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics;
import com.github.retrooper.packetevents.PacketEvents;
import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandCompleter;
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandHandler;
import kernitus.plugin.OldCombatMechanics.hooks.PlaceholderAPIHook;
import kernitus.plugin.OldCombatMechanics.hooks.api.Hook;
import kernitus.plugin.OldCombatMechanics.module.*;
import kernitus.plugin.OldCombatMechanics.updater.ModuleUpdateChecker;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker;
import kernitus.plugin.OldCombatMechanics.utilities.damage.EntityDamageByEntityListener;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import kernitus.plugin.OldCombatMechanics.utilities.storage.ModesetListener;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimpleBarChart;
import org.bstats.charts.SimplePie;
import org.bukkit.Bukkit;
import org.bukkit.entity.HumanEntity;
import org.bukkit.event.EventException;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.Collectors;
public class OCMMain extends JavaPlugin {
private static OCMMain INSTANCE;
private final Logger logger = getLogger();
private final OCMConfigHandler CH = new OCMConfigHandler(this);
private final List disableListeners = new ArrayList<>();
private final List enableListeners = new ArrayList<>();
private final List hooks = new ArrayList<>();
public OCMMain() {
super();
}
@Override
public void onLoad() {
PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this));
PacketEvents.getAPI().load();
}
@Override
public void onEnable() {
INSTANCE = this;
// Setting up config.yml
CH.setupConfigIfNotPresent();
// Initialise persistent player storage
PlayerStorage.initialise(this);
// Initialise ModuleLoader utility
ModuleLoader.initialise(this);
// Initialise Config utility
Config.initialise(this);
// Initialise the Messenger utility
Messenger.initialise(this);
PacketEvents.getAPI().init();
// Register all the modules
registerModules();
// Register all hooks for integrating with other plugins
registerHooks();
// Initialise all the hooks
hooks.forEach(hook -> hook.init(this));
// Set up the command handler
getCommand("OldCombatMechanics").setExecutor(new OCMCommandHandler(this));
// Set up command tab completer
getCommand("OldCombatMechanics").setTabCompleter(new OCMCommandCompleter());
Config.reload();
// BStats Metrics
final Metrics metrics = new Metrics(this, 53);
metrics.addCustomChart(new SimplePie("server_software", () -> {
final String name = Bukkit.getServer().getName();
if (name == null || name.isEmpty()) return "Unknown";
final String cleaned = name.split("\\s", 2)[0].trim();
return cleaned.isEmpty() ? "Unknown" : cleaned;
}));
// Simple bar chart (kept in case bStats re-enables bar display)
metrics.addCustomChart(
new SimpleBarChart(
"enabled_modules",
() -> ModuleLoader.getModules().stream()
.filter(OCMModule::isEnabled)
.collect(Collectors.toMap(OCMModule::toString, module -> 1))));
// Pie chart of enabled/disabled for each module
ModuleLoader.getModules().forEach(module -> metrics.addCustomChart(
new SimplePie(module.getModuleName() + "_pie",
() -> module.isEnabled() ? "enabled" : "disabled")));
// Simple pie: exact count of enabled modules per server (as a string key).
metrics.addCustomChart(new SimplePie("enabled_modules_count", () -> {
int count = (int) ModuleLoader.getModules().stream().filter(OCMModule::isEnabled).count();
return Integer.toString(count);
}));
enableListeners.forEach(Runnable::run);
// Properly handle Plugman load/unload.
final List joinListeners = Arrays
.stream(PlayerJoinEvent.getHandlerList().getRegisteredListeners())
.filter(registeredListener -> registeredListener.getPlugin().equals(this))
.collect(Collectors.toList());
Bukkit.getOnlinePlayers().forEach(player -> {
final PlayerJoinEvent event = new PlayerJoinEvent(player, "");
// Trick all the modules into thinking the player just joined in case the plugin
// was loaded with Plugman.
// This way attack speeds, item modifications, etc. will be applied immediately
// instead of after a re-log.
joinListeners.forEach(registeredListener -> {
try {
registeredListener.callEvent(event);
} catch (EventException e) {
e.printStackTrace();
}
});
});
// Logging to console the enabling of OCM
final PluginDescriptionFile pdfFile = this.getDescription();
logger.info(pdfFile.getName() + " v" + pdfFile.getVersion() + " has been enabled");
if (Config.moduleEnabled("update-checker"))
Bukkit.getScheduler().runTaskLaterAsynchronously(this,
() -> new UpdateChecker(this).performUpdate(), 20L);
metrics.addCustomChart(new SimplePie("auto_update_pie",
() -> Config.moduleSettingEnabled("update-checker",
"auto-update") ? "enabled" : "disabled"));
}
@Override
public void onDisable() {
final PluginDescriptionFile pdfFile = this.getDescription();
disableListeners.forEach(Runnable::run);
// Properly handle Plugman load/unload.
final List quitListeners = Arrays
.stream(PlayerQuitEvent.getHandlerList().getRegisteredListeners())
.filter(registeredListener -> registeredListener.getPlugin().equals(this))
.collect(Collectors.toList());
// Trick all the modules into thinking the player just quit in case the plugin
// was unloaded with Plugman.
// This way attack speeds, item modifications, etc. will be restored immediately
// instead of after a disconnect.
Bukkit.getOnlinePlayers().forEach(player -> {
final PlayerQuitEvent event = new PlayerQuitEvent(player, "");
quitListeners.forEach(registeredListener -> {
try {
registeredListener.callEvent(event);
} catch (EventException e) {
e.printStackTrace();
}
});
});
PlayerStorage.instantSave();
PacketEvents.getAPI().terminate();
// Logging to console the disabling of OCM
logger.info(pdfFile.getName() + " v" + pdfFile.getVersion() + " has been disabled");
}
private void registerModules() {
// Update Checker (also a module, so we can use the dynamic
// registering/unregistering)
ModuleLoader.addModule(new ModuleUpdateChecker(this));
// Modeset listener, for when player joins or changes world
ModuleLoader.addModule(new ModesetListener(this));
// Module listeners
ModuleLoader.addModule(new ModuleAttackCooldown(this));
ModuleLoader.addModule(new ModuleAttackRange(this));
// If below 1.16, we need to keep track of player attack cooldown ourselves
if (Reflector.getMethod(HumanEntity.class, "getAttackCooldown", 0) == null) {
ModuleLoader.addModule(new AttackCooldownTracker(this));
}
// Listeners registered later with same priority are called later
// These four listen to OCMEntityDamageByEntityEvent:
ModuleLoader.addModule(new ModuleOldToolDamage(this));
ModuleLoader.addModule(new ModuleSwordSweep(this));
ModuleLoader.addModule(new ModuleOldPotionEffects(this));
ModuleLoader.addModule(new ModuleOldCriticalHits(this));
// Next block are all on LOWEST priority, so will be called in the following
// order:
// Damage order: base -> potion effects -> critical hit -> enchantments
// Defence order: overdamage -> blocking -> armour -> resistance -> armour enchs
// -> absorption
// EntityDamageByEntityListener calls OCMEntityDamageByEntityEvent, see modules
// above
// For everything from base to overdamage
ModuleLoader.addModule(new EntityDamageByEntityListener(this));
// ModuleSwordBlocking to calculate blocking
ModuleLoader.addModule(new ModuleShieldDamageReduction(this));
// OldArmourStrength for armour -> resistance -> armour enchs -> absorption
ModuleLoader.addModule(new ModuleOldArmourStrength(this));
ModuleLoader.addModule(new ModuleSwordBlocking(this));
ModuleLoader.addModule(new ModuleOldArmourDurability(this));
ModuleLoader.addModule(new ModuleGoldenApple(this));
ModuleLoader.addModule(new ModuleFishingKnockback(this));
ModuleLoader.addModule(new ModulePlayerKnockback(this));
ModuleLoader.addModule(new ModulePlayerRegen(this));
ModuleLoader.addModule(new ModuleDisableCrafting(this));
ModuleLoader.addModule(new ModuleDisableOffHand(this));
ModuleLoader.addModule(new ModuleOldBrewingStand(this));
ModuleLoader.addModule(new ModuleProjectileKnockback(this));
ModuleLoader.addModule(new ModuleDisableEnderpearlCooldown(this));
ModuleLoader.addModule(new ModuleChorusFruit(this));
ModuleLoader.addModule(new ModuleOldBurnDelay(this));
ModuleLoader.addModule(new ModuleAttackFrequency(this));
ModuleLoader.addModule(new ModuleFishingRodVelocity(this));
ModuleLoader.addModule(new ModuleAttackSounds(this));
ModuleLoader.addModule(new ModuleSwordSweepParticles(this));
}
private void registerHooks() {
if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI"))
hooks.add(new PlaceholderAPIHook());
}
public void upgradeConfig() {
CH.upgradeConfig();
}
public boolean doesConfigExist() {
return CH.doesConfigExist();
}
/**
* Registers a runnable to run when the plugin gets disabled.
*
* @param action the {@link Runnable} to run when the plugin gets disabled
*/
public void addDisableListener(Runnable action) {
disableListeners.add(action);
}
/**
* Registers a runnable to run when the plugin gets enabled.
*
* @param action the {@link Runnable} to run when the plugin gets enabled
*/
public void addEnableListener(Runnable action) {
enableListeners.add(action);
}
/**
* Get the plugin's JAR file
*
* @return The File object corresponding to this plugin
*/
@NotNull
@Override
public File getFile() {
return super.getFile();
}
public static OCMMain getInstance() {
return INSTANCE;
}
public static String getVersion() {
return INSTANCE.getDescription().getVersion();
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/UpdateChecker.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.updater.SpigetUpdateChecker;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class UpdateChecker {
private final SpigetUpdateChecker updater;
private final boolean autoDownload;
private final OCMMain plugin;
public UpdateChecker(OCMMain plugin) {
updater = new SpigetUpdateChecker();
this.plugin = plugin;
// We don't really want to auto update if the config is not going to be upgraded automatically
autoDownload = Config.moduleSettingEnabled("update-checker", "auto-update") &&
(Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||
Config.getConfig().getBoolean("force-below-1-18-1-config-upgrade", false)
);
}
public void performUpdate() {
performUpdate(null);
}
public void performUpdate(@Nullable Player player) {
if (player != null)
update(player::sendMessage);
else
update(Messenger::info);
}
private void update(Consumer target) {
final List messages = new ArrayList<>();
if (updater.isUpdateAvailable()) {
messages.add(ChatColor.BLUE + "An update for OldCombatMechanics to version " + updater.getLatestVersion() + " is available!");
if (!autoDownload) {
messages.add(ChatColor.BLUE + "Click here to download it: " + ChatColor.GRAY + updater.getUpdateURL());
} else {
messages.add(ChatColor.BLUE + "Downloading update: " + ChatColor.GRAY + updater.getUpdateURL());
try {
if (updater.downloadLatestVersion(plugin.getServer().getUpdateFolderFile(), plugin.getFile().getName()))
messages.add(ChatColor.GREEN + "Update downloaded. Restart or reload server to enable new version.");
else throw new RuntimeException();
} catch (Exception e) {
messages.add(ChatColor.RED + "Error occurred while downloading update! Check console for more details");
e.printStackTrace();
}
}
}
messages.forEach(target);
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/commands/OCMCommandCompleter.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.commands;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static kernitus.plugin.OldCombatMechanics.commands.OCMCommandHandler.Subcommand;
/**
* Provides tab completion for OCM commands
*/
public class OCMCommandCompleter implements TabCompleter {
@Nullable
@Override
public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
final List completions = new ArrayList<>();
if (args.length < 2) {
completions.addAll(Arrays.stream(Subcommand.values())
.filter(arg -> arg.toString().startsWith(args[0]))
.filter(arg -> OCMCommandHandler.checkPermissions(sender, arg))
.map(Enum::toString).collect(Collectors.toList()));
} else if (args[0].equalsIgnoreCase(Subcommand.mode.toString())) {
if (args.length < 3) {
if (sender.hasPermission("oldcombatmechanics.mode.others")
|| sender.hasPermission("oldcombatmechanics.mode.own")
) {
if (sender instanceof Player) { // Get the modesets allowed in the world player is in
final World world = ((Player) sender).getWorld();
completions.addAll(
Config.getWorlds()
// If world not in config, all modesets allowed
.getOrDefault(world.getUID(), Config.getModesets().keySet())
.stream()
.filter(ms -> ms.startsWith(args[1]))
.collect(Collectors.toList()));
} else {
completions.addAll(Config.getModesets().keySet().stream()
.filter(ms -> ms.startsWith(args[1]))
.collect(Collectors.toList()));
}
}
} else if (sender.hasPermission("oldcombatmechanics.mode.others")) {
completions.addAll(Bukkit.getOnlinePlayers().stream()
.map(Player::getName)
.filter(arg -> arg.startsWith(args[2]))
.collect(Collectors.toList()));
}
}
return completions;
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/commands/OCMCommandHandler.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.commands;
import kernitus.plugin.OldCombatMechanics.ModuleLoader;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerData;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.PluginDescriptionFile;
import org.jetbrains.annotations.NotNull;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
public class OCMCommandHandler implements CommandExecutor {
private static final String NO_PERMISSION = "&cYou need the permission '%s' to do that!";
private final OCMMain plugin;
enum Subcommand {
reload, mode
}
public OCMCommandHandler(OCMMain instance) {
this.plugin = instance;
}
private void help(OCMMain plugin, CommandSender sender) {
final PluginDescriptionFile description = plugin.getDescription();
Messenger.sendNoPrefix(sender, ChatColor.DARK_GRAY + Messenger.HORIZONTAL_BAR);
Messenger.sendNoPrefix(sender, "&6&lOldCombatMechanics&e by &ckernitus&e and &cRayzr522&e version &6%s",
description.getVersion());
if (checkPermissions(sender, Subcommand.reload))
Messenger.sendNoPrefix(sender, "&eYou can use &c/ocm reload&e to reload the config file");
if (checkPermissions(sender, Subcommand.mode))
Messenger.sendNoPrefix(sender,
Config.getConfig().getString("mode-messages.message-usage",
"&4ERROR: &rmode-messages.message-usage string missing"));
Messenger.sendNoPrefix(sender, ChatColor.DARK_GRAY + Messenger.HORIZONTAL_BAR);
}
private void reload(CommandSender sender) {
Config.reload();
Messenger.sendNoPrefix(sender, "&6&lOldCombatMechanics&e config file reloaded");
}
private void mode(CommandSender sender, String[] args) {
// If just /ocm mode
if (args.length < 2) {
if (sender instanceof Player) {
final Player player = ((Player) sender);
final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());
String modeName = playerData.getModesetForWorld(player.getWorld().getUID());
if (modeName == null || modeName.isEmpty())
modeName = "unknown";
Messenger.send(sender,
Config.getConfig().getString("mode-messages.mode-status",
"&4ERROR: &rmode-messages.mode-status string missing"),
modeName);
}
Messenger.send(sender,
Config.getConfig().getString("mode-messages.message-usage",
"&4ERROR: &rmode-messages.message-usage string missing"));
return;
}
final String modesetName = args[1].toLowerCase(Locale.ROOT);
if (!Config.getModesets().containsKey(modesetName)) {
Messenger.send(sender,
Config.getConfig().getString("mode-messages.invalid-modeset",
"&4ERROR: &rmode-messages.invalid-modeset string missing"));
return;
}
Player player = null;
// If /ocm mode
if (args.length < 3) {
if (sender instanceof Player) {
if (!sender.hasPermission("oldcombatmechanics.mode.own")) {
Messenger.sendNoPrefix(sender, NO_PERMISSION, "oldcombatmechanics.mode.own");
return;
}
player = (Player) sender;
} else {
Messenger.send(sender,
Config.getConfig().getString("mode-messages.invalid-player",
"&4ERROR: &rmode-messages.invalid-player string missing"));
return;
}
} else { // If /ocm mode
if (!sender.hasPermission("oldcombatmechanics.mode.others")) {
Messenger.sendNoPrefix(sender, NO_PERMISSION, "oldcombatmechanics.mode.others");
return;
}
player = Bukkit.getPlayer(args[2]);
}
if (player == null) {
Messenger.send(sender,
Config.getConfig().getString("mode-messages.invalid-player",
"&4ERROR: &rmode-messages.invalid-player string missing"));
return;
}
final UUID worldId = player.getWorld().getUID();
final Set worldModesets = Config.getWorlds().get(worldId);
// If modesets null or empty it means not configured, so all are allowed
if (worldModesets != null && !worldModesets.isEmpty() && !worldModesets.contains(modesetName)) {
// Modeset not allowed in current world
Messenger.send(sender,
Config.getConfig().getString("mode-messages.invalid-modeset",
"&4ERROR: &rmode-messages.invalid-modeset string missing"));
return;
}
final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());
playerData.setModesetForWorld(worldId, modesetName);
PlayerStorage.setPlayerData(player.getUniqueId(), playerData);
PlayerStorage.scheduleSave();
Messenger.send(sender,
Config.getConfig().getString("mode-messages.mode-set",
"&4ERROR: &rmode-messages.mode-set string missing"),
modesetName);
// Re-apply things like attack speed and collision team
final Player playerCopy = player;
ModuleLoader.getModules().forEach(module -> module.onModesetChange(playerCopy));
}
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label,
String[] args) {
if (args.length < 1) {
help(plugin, sender);
} else {
try {
try {
final Subcommand subcommand = Subcommand.valueOf(args[0].toLowerCase(Locale.ROOT));
if (checkPermissions(sender, subcommand, true)) {
switch (subcommand) {
case reload:
reload(sender);
break;
case mode:
mode(sender, args);
break;
default:
throw new CommandNotRecognisedException();
}
}
} catch (IllegalArgumentException e) {
throw new CommandNotRecognisedException();
}
} catch (CommandNotRecognisedException e) {
Messenger.send(sender, "Subcommand not recognised!");
}
}
return true;
}
private static class CommandNotRecognisedException extends IllegalArgumentException {
}
static boolean checkPermissions(CommandSender sender, Subcommand subcommand) {
return checkPermissions(sender, subcommand, false);
}
static boolean checkPermissions(CommandSender sender, Subcommand subcommand, boolean sendMessage) {
final boolean hasPermission = sender.hasPermission("oldcombatmechanics." + subcommand);
if (sendMessage && !hasPermission)
Messenger.sendNoPrefix(sender, NO_PERMISSION, "oldcombatmechanics." + subcommand);
return hasPermission;
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/hooks/PlaceholderAPIHook.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.hooks;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.hooks.api.Hook;
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableEnderpearlCooldown;
import kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerData;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
public class PlaceholderAPIHook implements Hook {
private PlaceholderExpansion expansion;
@Override
public void init(OCMMain plugin) {
expansion = new PlaceholderExpansion() {
@Override
public boolean canRegister() {
return true;
}
@Override
public boolean persist() {
return true;
}
@Override
public @NotNull String getIdentifier() {
return "ocm";
}
@Override
public @NotNull String getAuthor() {
return String.join(", ", plugin.getDescription().getAuthors());
}
@Override
public @NotNull String getVersion() {
return plugin.getDescription().getVersion();
}
@Override
public String onPlaceholderRequest(Player player, @NotNull String identifier) {
if (player == null) return null;
switch (identifier) {
case "modeset":
return getModeset(player);
case "gapple_cooldown":
return getGappleCooldown(player);
case "napple_cooldown":
return getNappleCooldown(player);
case "enderpearl_cooldown":
return getEnderpearlCooldown(player);
}
return null;
}
private String getGappleCooldown(Player player) {
final long seconds = ModuleGoldenApple.getInstance().getGappleCooldown(player.getUniqueId());
return seconds > 0 ? String.valueOf(seconds) : "None";
}
private String getNappleCooldown(Player player) {
final long seconds = ModuleGoldenApple.getInstance().getNappleCooldown(player.getUniqueId());
return seconds > 0 ? String.valueOf(seconds) : "None";
}
private String getEnderpearlCooldown(Player player) {
final long seconds = ModuleDisableEnderpearlCooldown.getInstance().getEnderpearlCooldown(player.getUniqueId());
return seconds > 0 ? String.valueOf(seconds) : "None";
}
private String getModeset(Player player) {
final PlayerData playerData = PlayerStorage.getPlayerData(player.getUniqueId());
String modeName = playerData.getModesetForWorld(player.getWorld().getUID());
if (modeName == null || modeName.isEmpty()) modeName = "unknown";
return modeName;
}
};
expansion.register();
}
@Override
public void deinit(OCMMain plugin) {
if (expansion != null) {
expansion.unregister();
}
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/hooks/api/Hook.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.hooks.api;
import kernitus.plugin.OldCombatMechanics.OCMMain;
public interface Hook {
void init(OCMMain plugin);
void deinit(OCMMain plugin);
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackCooldown.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import com.cryptomorin.xseries.XAttribute;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerItemHeldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Collections;
import java.util.Map;
/**
* Disables the attack cooldown.
*/
public class ModuleAttackCooldown extends OCMModule {
private static final double VANILLA_ATTACK_SPEED = 4.0;
private double genericAttackSpeed = 40.0;
private Map heldItemAttackSpeeds = Collections.emptyMap();
public ModuleAttackCooldown(OCMMain plugin) {
super(plugin, "disable-attack-cooldown");
}
@Override
public void reload() {
genericAttackSpeed = module().getDouble("generic-attack-speed", 40.0);
heldItemAttackSpeeds = Collections.emptyMap();
if (module().isConfigurationSection("held-item-attack-speeds")) {
heldItemAttackSpeeds = ConfigUtils.loadMaterialDoubleMap(module().getConfigurationSection("held-item-attack-speeds"));
}
Bukkit.getOnlinePlayers().forEach(this::adjustAttackSpeed);
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerLogin(PlayerJoinEvent e) {
adjustAttackSpeed(e.getPlayer());
}
@EventHandler(priority = EventPriority.HIGH)
public void onWorldChange(PlayerChangedWorldEvent e) {
adjustAttackSpeed(e.getPlayer());
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onHotbarChange(PlayerItemHeldEvent e) {
if (e.isCancelled()) {
adjustAttackSpeed(e.getPlayer());
} else {
adjustAttackSpeed(e.getPlayer(), e.getPlayer().getInventory().getItem(e.getNewSlot()));
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onSwapHandItems(PlayerSwapHandItemsEvent e) {
if (e.isCancelled()) {
adjustAttackSpeed(e.getPlayer());
} else {
adjustAttackSpeed(e.getPlayer(), e.getOffHandItem());
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerQuit(PlayerQuitEvent e) {
setAttackSpeed(e.getPlayer(), VANILLA_ATTACK_SPEED);
}
/**
* Adjusts the attack speed to the default or configured value, depending on
* whether the module is enabled.
*
* @param player the player to set the attack speed for
*/
private void adjustAttackSpeed(Player player) {
adjustAttackSpeed(player, player.getInventory().getItemInMainHand());
}
private void adjustAttackSpeed(Player player, ItemStack mainHand) {
final double attackSpeed = isEnabled(player)
? getConfiguredAttackSpeed(mainHand)
: VANILLA_ATTACK_SPEED;
setAttackSpeed(player, attackSpeed);
}
@Override
public void onModesetChange(Player player) {
adjustAttackSpeed(player);
}
private double getConfiguredAttackSpeed(ItemStack itemStack) {
if (itemStack == null) {
return genericAttackSpeed;
}
return heldItemAttackSpeeds.getOrDefault(itemStack.getType(), genericAttackSpeed);
}
/**
* Sets the attack speed to the given value.
*
* @param player the player to set it for
* @param attackSpeed the attack speed to set it to
*/
public void setAttackSpeed(Player player, double attackSpeed) {
final AttributeInstance attribute = player.getAttribute(XAttribute.ATTACK_SPEED.get());
if (attribute == null)
return;
final double baseValue = attribute.getBaseValue();
if (baseValue != attackSpeed) {
debug(String.format("Setting attack speed to %.2f (was: %.2f)", attackSpeed, baseValue), player);
attribute.setBaseValue(attackSpeed);
}
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackFrequency.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
public class ModuleAttackFrequency extends OCMModule {
private static final int DEFAULT_DELAY = 20;
private static int playerDelay, mobDelay;
public ModuleAttackFrequency(OCMMain plugin) {
super(plugin, "attack-frequency");
reload();
}
@Override
public void reload() {
playerDelay = module().getInt("playerDelay");
mobDelay = module().getInt("mobDelay");
Bukkit.getWorlds().forEach(world -> world.getLivingEntities().forEach(livingEntity -> {
if (livingEntity instanceof Player)
livingEntity.setMaximumNoDamageTicks(isEnabled((Player) livingEntity) ? playerDelay : DEFAULT_DELAY);
else
livingEntity.setMaximumNoDamageTicks(isEnabled(world) ? mobDelay : DEFAULT_DELAY);
}));
}
@EventHandler
public void onPlayerJoin(PlayerJoinEvent e) {
final Player player = e.getPlayer();
if (isEnabled(player)) setDelay(player, playerDelay);
}
@EventHandler
public void onPlayerLogout(PlayerQuitEvent e) {
setDelay(e.getPlayer(), DEFAULT_DELAY);
}
@EventHandler
public void onPlayerChangeWorld(PlayerChangedWorldEvent e) {
final Player player = e.getPlayer();
setDelay(player, isEnabled(player) ? playerDelay : DEFAULT_DELAY);
}
@EventHandler
public void onPlayerRespawn(PlayerRespawnEvent e) {
final Player player = e.getPlayer();
setDelay(player, isEnabled(player) ? playerDelay : DEFAULT_DELAY);
}
private void setDelay(Player player, int delay) {
player.setMaximumNoDamageTicks(delay);
debug("Set hit delay to " + delay, player);
}
@EventHandler
public void onCreatureSpawn(CreatureSpawnEvent e) {
final LivingEntity livingEntity = e.getEntity();
final World world = livingEntity.getWorld();
if (isEnabled(world)) livingEntity.setMaximumNoDamageTicks(mobDelay);
}
@EventHandler
public void onEntityTeleportEvent(EntityTeleportEvent e) {
// This event is only fired for non-player entities
final Entity entity = e.getEntity();
if (!(entity instanceof LivingEntity)) return;
final LivingEntity livingEntity = (LivingEntity) entity;
final World fromWorld = e.getFrom().getWorld();
final Location toLocation = e.getTo();
if(toLocation == null) return;
final World toWorld = toLocation.getWorld();
if (fromWorld.getUID() != toWorld.getUID())
livingEntity.setMaximumNoDamageTicks(isEnabled(toWorld) ? mobDelay : DEFAULT_DELAY);
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackRange.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerItemHeldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.Arrays;
import java.util.Locale;
import java.util.function.Predicate;
import java.lang.reflect.Method;
/**
* Applies the 1.8-style attack range (reach + hitbox margin) to melee weapons on 1.21.11+ Paper.
* Gracefully disables itself on Spigot or older versions where the AttackRange data component is absent.
*/
public class ModuleAttackRange extends OCMModule implements Listener {
private static final String[] WEAPONS = {"sword", "axe", "pickaxe", "spade", "shovel", "hoe", "trident", "mace"};
private boolean supported;
private float minRange;
private float maxRange;
private float minCreative;
private float maxCreative;
private float hitboxMargin;
private float mobFactor;
private PaperAttackRangeAdapter paperAdapter;
public ModuleAttackRange(OCMMain plugin) {
super(plugin, "attack-range");
initialiseReflection();
registerCleanerListener(plugin);
reload();
}
private void initialiseReflection() {
if (!Reflector.versionIsNewerOrEqualTo(1, 21, 11)) {
supported = false;
return;
}
try {
paperAdapter = new PaperAttackRangeAdapter();
supported = true;
} catch (Throwable t) {
supported = false;
Messenger.warn("Attack range component API not available (Paper 1.21.11+ required); module disabled. (" + t.getClass().getSimpleName() + ": " + t.getMessage() + ")");
}
}
@Override
public void reload() {
if (!supported) return;
minRange = (float) module().getDouble("min-range", 0.0);
maxRange = (float) module().getDouble("max-range", 3.0);
minCreative = (float) module().getDouble("min-creative-range", 0.0);
maxCreative = (float) module().getDouble("max-creative-range", 4.0);
hitboxMargin = (float) module().getDouble("hitbox-margin", 0.1);
mobFactor = (float) module().getDouble("mob-factor", 1.0);
// Apply to currently online players so config changes take effect immediately
Bukkit.getOnlinePlayers().forEach(this::applyToHeld);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onJoin(PlayerJoinEvent event) {
applyToHeld(event.getPlayer());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onHotbar(PlayerItemHeldEvent event) {
// strip old, then apply/strip new
cleanHand(event.getPlayer(), event.getPreviousSlot());
applyToHeld(event.getPlayer());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSwap(PlayerSwapHandItemsEvent event) {
normaliseSwapEvent(event);
reconcileSwapInventory(event.getPlayer());
}
private void normaliseSwapEvent(PlayerSwapHandItemsEvent event) {
Player player = event.getPlayer();
if (!supported) return;
ItemStack postSwapMainHand = event.getOffHandItem();
ItemStack postSwapOffHand = event.getMainHandItem();
stripComponent(postSwapOffHand);
applyToItem(player, postSwapMainHand);
// Persist adjusted stacks into event payload for synthetic/manual swap flows.
event.setOffHandItem(postSwapMainHand);
event.setMainHandItem(postSwapOffHand);
}
private void reconcileSwapInventory(Player player) {
if (!supported) return;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
ItemStack mainHand = player.getInventory().getItemInMainHand();
ItemStack offHand = player.getInventory().getItemInOffHand();
stripComponent(mainHand);
stripComponent(offHand);
applyToItem(player, mainHand);
});
}
private void applyToHeld(Player player) {
if (!supported) return;
ItemStack item = player.getInventory().getItemInMainHand();
if (item == null || item.getType() == Material.AIR) return;
applyToItem(player, item);
}
private void applyToItem(Player player, ItemStack item) {
if (item == null || item.getType() == Material.AIR || !isWeapon(item.getType())) {
stripComponent(item);
return;
}
if (!isEnabled(player)) {
stripComponent(item);
return;
}
applyAttackRange(item);
}
private void cleanHand(Player player, int slot) {
ItemStack old = player.getInventory().getItem(slot);
stripComponent(old);
}
private boolean isWeapon(Material material) {
final String name = material.name().toLowerCase(Locale.ROOT);
return Arrays.stream(WEAPONS).anyMatch(name::endsWith);
}
private void applyAttackRange(ItemStack item) {
paperAdapter.apply(item, minRange, maxRange, minCreative, maxCreative, hitboxMargin, mobFactor);
}
private void stripComponent(ItemStack item) {
if (!supported || paperAdapter == null || item == null) return;
paperAdapter.clear(item);
}
private void registerCleanerListener(Plugin plugin) {
Bukkit.getPluginManager().registerEvents(new CleanerListener(), plugin);
}
/**
* Always-on listener that strips the component when the item leaves hand or is dropped,
* preventing lingering modified stacks even when the module is disabled.
*/
private class CleanerListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onHeldChange(PlayerItemHeldEvent event) {
cleanHand(event.getPlayer(), event.getPreviousSlot());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSwap(PlayerSwapHandItemsEvent event) {
// Handled by the module listener; avoid clobbering its swap normalisation.
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDrop(PlayerDropItemEvent event) {
stripComponent(event.getItemDrop().getItemStack());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDeath(PlayerDeathEvent event) {
event.getDrops().forEach(ModuleAttackRange.this::stripComponent);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onQuit(PlayerQuitEvent event) {
stripComponent(event.getPlayer().getInventory().getItemInMainHand());
stripComponent(event.getPlayer().getInventory().getItemInOffHand());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onWorldChange(PlayerChangedWorldEvent event) {
stripComponent(event.getPlayer().getInventory().getItemInMainHand());
stripComponent(event.getPlayer().getInventory().getItemInOffHand());
applyToHeld(event.getPlayer());
}
}
/**
* Paper-only adapter to avoid reflection in hot paths.
*/
private static class PaperAttackRangeAdapter {
@SuppressWarnings("unchecked")
private static final Predicate COPY_ALL_COMPONENTS = ignored -> true;
private final Object attackRangeType;
private final java.lang.reflect.Method attackRangeFactory;
private final java.lang.reflect.Method minReachSetter;
private final java.lang.reflect.Method maxReachSetter;
private final java.lang.reflect.Method minCreativeSetter;
private final java.lang.reflect.Method maxCreativeSetter;
private final java.lang.reflect.Method hitboxSetter;
private final java.lang.reflect.Method mobFactorSetter;
private final java.lang.reflect.Method buildMethod;
private final java.lang.reflect.Method itemSetData;
private final java.lang.reflect.Method itemHasData;
private final java.lang.reflect.Method itemUnsetData;
private final java.lang.reflect.Method itemEnsureServerConversions;
private final java.lang.reflect.Method itemCopyDataFrom;
private boolean warned;
PaperAttackRangeAdapter() throws Exception {
Class> dct = Class.forName("io.papermc.paper.datacomponent.DataComponentTypes");
Class> ar = Class.forName("io.papermc.paper.datacomponent.item.AttackRange");
Class> builder = Class.forName("io.papermc.paper.datacomponent.item.AttackRange$Builder");
attackRangeType = dct.getField("ATTACK_RANGE").get(null);
attackRangeFactory = ar.getMethod("attackRange");
minReachSetter = builder.getMethod("minReach", float.class);
maxReachSetter = builder.getMethod("maxReach", float.class);
minCreativeSetter = builder.getMethod("minCreativeReach", float.class);
maxCreativeSetter = builder.getMethod("maxCreativeReach", float.class);
hitboxSetter = builder.getMethod("hitboxMargin", float.class);
mobFactorSetter = builder.getMethod("mobFactor", float.class);
buildMethod = builder.getMethod("build");
Class> dctClass = Class.forName("io.papermc.paper.datacomponent.DataComponentType");
itemSetData = findSetDataMethod(dctClass, ar);
itemHasData = ItemStack.class.getMethod("hasData", dctClass);
itemUnsetData = ItemStack.class.getMethod("unsetData", dctClass);
Method ensureMethod = null;
Method copyMethod = null;
try {
ensureMethod = ItemStack.class.getMethod("ensureServerConversions");
copyMethod = ItemStack.class.getMethod("copyDataFrom", ItemStack.class, Predicate.class);
} catch (NoSuchMethodException ignored) {
// Older/newer API shape; keep as best-effort no-op.
}
itemEnsureServerConversions = ensureMethod;
itemCopyDataFrom = copyMethod;
}
private Method findSetDataMethod(Class> dctClass, Class> valueClass) throws NoSuchMethodException {
for (Method m : ItemStack.class.getMethods()) {
if (!m.getName().equals("setData")) continue;
Class>[] params = m.getParameterTypes();
if (params.length != 2) continue;
// accept any data component type class
if (!dctClass.isAssignableFrom(params[0]) && !params[0].getName().contains("DataComponentType")) continue;
if (!params[1].isAssignableFrom(valueClass) && !valueClass.isAssignableFrom(params[1]) && !params[1].isAssignableFrom(Object.class)) continue;
return m;
}
throw new NoSuchMethodException(ItemStack.class.getName() + "#setData(DataComponentType, AttackRange)");
}
void apply(ItemStack stack, float min, float max, float minCreative, float maxCreative, float margin, float mobFactor) {
try {
Object builder = attackRangeFactory.invoke(null);
invokeSetter(minReachSetter, builder, min);
invokeSetter(maxReachSetter, builder, max);
invokeSetter(minCreativeSetter, builder, minCreative);
invokeSetter(maxCreativeSetter, builder, maxCreative);
invokeSetter(hitboxSetter, builder, margin);
invokeSetter(mobFactorSetter, builder, mobFactor);
Object arObj = buildMethod.invoke(builder);
itemSetData.invoke(stack, attackRangeType, arObj);
ensureServerConversions(stack);
} catch (Throwable t) {
if (!warned) {
Messenger.warn("Attack range component application failed; leaving item unchanged. (" + t.getClass().getSimpleName() + ": " + t.getMessage() + ")");
warned = true;
}
}
}
private void invokeSetter(Method setter, Object builder, float value) throws Exception {
Object result = setter.invoke(builder, value);
if (result != null && !setter.getReturnType().equals(void.class) && !setter.getReturnType().equals(Void.class)) {
// Some Paper versions return the builder for chaining; others mutate in place.
// We do not need to capture the returned value because all calls target the same instance.
}
}
boolean hasComponent(ItemStack stack) {
try {
return (boolean) itemHasData.invoke(stack, attackRangeType);
} catch (Throwable t) {
return false;
}
}
void clear(ItemStack stack) {
try {
if (hasComponent(stack)) {
itemUnsetData.invoke(stack, attackRangeType);
ensureServerConversions(stack);
}
} catch (Throwable ignored) {
// ignore
}
}
private void ensureServerConversions(ItemStack stack) {
if (stack == null || itemEnsureServerConversions == null || itemCopyDataFrom == null) return;
try {
Object converted = itemEnsureServerConversions.invoke(stack);
if (!(converted instanceof ItemStack)) return;
if (converted == stack) return;
itemCopyDataFrom.invoke(stack, converted, COPY_ALL_COMPONENTS);
} catch (Throwable ignored) {
// no-op: best-effort sync only
}
}
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleAttackSounds.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import com.github.retrooper.packetevents.PacketEvents;
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
import com.github.retrooper.packetevents.event.PacketSendEvent;
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSoundEffect;
import com.cryptomorin.xseries.XSound;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.lang.reflect.Method;
/**
* A module to disable the new attack sounds.
*/
public class ModuleAttackSounds extends OCMModule {
private final SoundListener soundListener = new SoundListener();
private final Set blockedSounds = new HashSet<>();
public ModuleAttackSounds(OCMMain plugin) {
super(plugin, "disable-attack-sounds");
reload();
}
@Override
public void reload() {
blockedSounds.clear();
blockedSounds.addAll(getBlockedSounds());
if (isEnabled() && !blockedSounds.isEmpty())
PacketEvents.getAPI().getEventManager().registerListener(soundListener);
else
PacketEvents.getAPI().getEventManager().unregisterListener(soundListener);
}
private Collection getBlockedSounds() {
List fromConfig = module().getStringList("blocked-sound-names");
Set processed = new HashSet<>();
for (String soundName : fromConfig) {
Optional xSound = XSound.matchXSound(soundName);
if (xSound.isPresent()) {
Sound sound = xSound.get().parseSound();
if (sound != null) {
// On modern versions, we can get the namespaced key directly
try {
Method getKeyMethod = Sound.class.getMethod("getKey");
Object key = getKeyMethod.invoke(sound);
processed.add(key.toString());
continue;
} catch (Exception ignored) {
// This server version doesn't have the getKey method, so we fall back to the
// legacy name
}
}
// Fallback for older versions or if the sound is not in the Bukkit enum
String processedName = soundName.toLowerCase(Locale.ROOT).replace('_', '.');
if (!processedName.contains(":")) {
processedName = "minecraft:" + processedName;
}
processed.add(processedName);
} else {
Messenger.warn("Invalid sound name in config: " + soundName);
}
}
return processed;
}
/**
* Disables attack sounds.
*/
private class SoundListener extends PacketListenerAbstract {
private boolean disabledDueToError;
@Override
public void onPacketSend(PacketSendEvent packetEvent) {
if (disabledDueToError || packetEvent.isCancelled())
return;
if (blockedSounds.isEmpty())
return;
final Object playerObject = packetEvent.getPlayer();
if (!(playerObject instanceof Player))
return;
final Player player = (Player) playerObject;
if (!isEnabled(player))
return;
final Object packetType = packetEvent.getPacketType();
if (!PacketType.Play.Server.NAMED_SOUND_EFFECT.equals(packetType)
&& !PacketType.Play.Server.SOUND_EFFECT.equals(packetType)) {
return;
}
try {
WrapperPlayServerSoundEffect wrapper = new WrapperPlayServerSoundEffect(packetEvent);
com.github.retrooper.packetevents.protocol.sound.Sound sound = wrapper.getSound();
if (sound == null || sound.getSoundId() == null)
return;
String soundName = sound.getSoundId().toString();
if (blockedSounds.contains(soundName)) {
packetEvent.setCancelled(true);
debug("Blocked sound " + soundName, player);
}
} catch (Exception | ExceptionInInitializerError e) {
disabledDueToError = true;
Messenger.warn(
e,
"Error detecting sound packets. Please report it along with the following exception " +
"on github.");
}
}
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleChorusFruit.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.MathsHelper;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.block.BlockFace;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import java.util.concurrent.ThreadLocalRandom;
/**
* A module to control chorus fruits.
*/
public class ModuleChorusFruit extends OCMModule {
public ModuleChorusFruit(OCMMain plugin) {
super(plugin, "chorus-fruit");
}
@EventHandler
public void onEat(PlayerItemConsumeEvent e) {
if (e.getItem().getType() != Material.CHORUS_FRUIT) return;
final Player player = e.getPlayer();
if (!isEnabled(player)) return;
if (module().getBoolean("prevent-eating")) {
e.setCancelled(true);
return;
}
final int hungerValue = module().getInt("hunger-value");
final double saturationValue = module().getDouble("saturation-value");
final int previousFoodLevel = player.getFoodLevel();
final float previousSaturation = player.getSaturation();
// Run it on the next tick to reset things while not cancelling the chorus fruit eat event
// This ensures the teleport event is fired and counts towards statistics
Bukkit.getScheduler().runTaskLater(plugin, () -> {
final int newFoodLevel = Math.min(hungerValue + previousFoodLevel, 20);
final float newSaturation = Math.min((float) (saturationValue + previousSaturation), newFoodLevel);
player.setFoodLevel(newFoodLevel);
player.setSaturation(newSaturation);
debug("Food level changed from: " + previousFoodLevel + " to " + player.getFoodLevel(), player);
}, 2L);
}
@EventHandler
public void onTeleport(PlayerTeleportEvent e) {
if (e.getCause() != PlayerTeleportEvent.TeleportCause.CHORUS_FRUIT) return;
final Player player = e.getPlayer();
if (!isEnabled(player)) return;
final double distance = getMaxTeleportationDistance();
if (distance == 8) {
debug("Using vanilla teleport implementation!", player);
return;
}
if (distance <= 0) {
debug("Chorus teleportation is not allowed", player);
e.setCancelled(true);
return;
}
// Not sure when this can occur, but it is marked as @Nullable
final Location toLocation = e.getTo();
if (toLocation == null) {
debug("Teleport target is null", player);
return;
}
final int maxheight = toLocation.getWorld().getMaxHeight();
final Location origin = player.getLocation();
final World world = origin.getWorld();
final ThreadLocalRandom rng = ThreadLocalRandom.current();
Location chosen = null;
// Mirror vanilla chorus fruit: up to 16 attempts to find a safe spot
for (int i = 0; i < 16; i++) {
final double x = origin.getX() + (rng.nextDouble() - 0.5D) * 2 * distance;
final double y = MathsHelper.clamp(origin.getY() + rng.nextInt(Math.max(1, (int) Math.ceil(distance))), 0,
maxheight - 1);
final double z = origin.getZ() + (rng.nextDouble() - 0.5D) * 2 * distance;
final Location candidate = new Location(world, x, y, z);
if (!world.getWorldBorder().isInside(candidate)) continue;
if (!isSafe(candidate)) continue;
chosen = candidate;
break;
}
if (chosen == null) {
debug("No safe chorus teleport found within distance " + distance + ", keeping vanilla target", player);
return;
}
e.setTo(chosen);
debug("Chorus teleport redirected to safe location " + chosen, player);
}
private double getMaxTeleportationDistance() {
return module().getDouble("max-teleportation-distance");
}
private boolean isSafe(Location location) {
Block feet = location.getBlock();
Block head = feet.getRelative(BlockFace.UP);
Block below = feet.getRelative(BlockFace.DOWN);
boolean modern = Reflector.versionIsNewerOrEqualTo(1, 13, 0);
boolean feetPassable = modern ? feet.isPassable() : !feet.getType().isSolid();
boolean headPassable = modern ? head.isPassable() : !head.getType().isSolid();
if (!feetPassable || !headPassable) return false;
if (!below.getType().isSolid()) return false;
return true;
}
}
================================================
FILE: src/main/java/kernitus/plugin/OldCombatMechanics/module/ModuleDisableCrafting.java
================================================
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics.module;
import kernitus.plugin.OldCombatMechanics.OCMMain;
import kernitus.plugin.OldCombatMechanics.utilities.ConfigUtils;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import org.bukkit.Material;
import org.bukkit.entity.HumanEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import java.util.List;
/**
* Makes the specified materials uncraftable.
*/
public class ModuleDisableCrafting extends OCMModule {
private List