Compare commits

...
733 Commits
Author SHA1 Message Date
NotAlexNoyle a635d07b96 Exclude CODEOWNERS from language statistics 2026-10-29 16:19:01 -04:00
NotAlexNoyle 7489435a1b Add CODEOWNERS (one staff approval) 2026-10-29 16:19:01 -04:00
NotAlexNoyle 7fe5ab669b Fix 1.21.11 offhand 2026-08-17 10:43:30 -04:00
NotAlexNoyle 3ce1e71695 more loyal 1.8 damage, invulnerability 2026-08-10 07:49:05 -04:00
NotAlexNoyle 8ab335d383 Fix mob attack frequenct 2026-08-09 14:36:00 -04:00
NotAlexNoyle 59e006c394 Fix rods 2026-07-13 18:03:54 -04:00
NotAlexNoyle e69567c520 Update config notes 2026-06-24 16:29:05 -04:00
NotAlexNoyle 9618ab0c1d Fix fishing rods 2026-06-22 07:38:30 -04:00
NotAlexNoyle d7623cc0d7 Update PacketEvents to 2.11.2 2026-06-17 13:47:57 -04:00
NotAlexNoyle 8055ed5502 Fix armor burn protection 2026-06-16 12:02:00 -04:00
NotAlexNoyle a5554fd8aa Fix fishing rod velocity 2026-06-16 11:47:31 -04:00
NotAlexNoyle cd86fbf66c Fix mob velocity 2026-06-16 08:17:38 -04:00
NotAlexNoyle 8db10c3e5e Add music disks to offhand whitelist 2026-06-16 06:52:11 -04:00
NotAlexNoyle fff3ca84e9 Add totem hotbar information message 2026-06-15 09:50:34 -04:00
NotAlexNoyle 18af8fa1bf Fix OCM Offhand restriction 2026-06-08 09:27:07 -04:00
NotAlexNoyle 3dddab3e50 Remove sounds after player has fully logged in 2026-06-04 12:00:05 -04:00
NotAlexNoyle e1e6bbe448 Update config.yml 2026-06-04 08:20:51 -04:00
NotAlexNoyle c56995f8c2 Disable sweep particles 2026-05-31 12:59:08 -04:00
NotAlexNoyle 7ae541c9cc Fix disabled modules 2026-05-25 11:53:13 -04:00
NotAlexNoyle ffd9d33a52 Fix jar 2026-05-25 11:15:47 -04:00
NotAlexNoyle 5802034fa5 Add eclipse 2026-05-25 10:39:38 -04:00
NotAlexNoyle 49dc96ab2f Update config file for v2.5.0 2026-05-25 04:51:35 -04:00
NotAlexNoyle b82292478a Merge upstream adc8380 (release 2.5.0) 2026-05-25 03:30:59 -04:00
NotAlexNoyle 144c3f5a05 Let KnockbackSync handle knockback 2026-05-25 03:25:42 -04:00
kernitus adc8380879 chore(master): release 2.5.0 2026-05-23 13:25:19 +01:00
kernitus 4ab16cff40 chore(style): enforce final newlines 2026-05-23 13:17:56 +01:00
kernitus b8bcb88758 docs: update api override guidance 2026-05-23 12:51:52 +01:00
kernitus 94549fec46 docs(api): explain modeset integration setup 2026-05-23 12:25:49 +01:00
kernitus 6f33807366 feat(api): publish player state change events 2026-05-23 12:19:53 +01:00
kernitus b3a6039918 feat(api): expose per-player modeset controls 2026-05-23 11:57:57 +01:00
kernitus 0484ead128 docs: update readme 2026-05-23 00:15:24 +01:00
kernitus e20fe988e9 feat: show build commit in version output 2026-05-22 23:45:57 +01:00
kernitus 2c68c94b7e build: generate optional commit metadata 2026-05-22 23:42:14 +01:00
kernitus 8c9d6e8f3d docs(modesets): explain mixed-mode ownership 2026-05-22 23:16:11 +01:00
kernitus a80f9c2872 test(modesets): cover environmental armour ownership 2026-05-22 23:11:06 +01:00
kernitus 9cdfd111eb test(modesets): cover mixed-mode knockback initiators 2026-05-22 23:02:57 +01:00
kernitus 869f03805e test(modesets): cover mixed-mode potion and critical hits 2026-05-22 22:54:02 +01:00
kernitus eb905f21b8 fix(tool-damage): apply mixed-mode weapon damage by attacker 2026-05-22 22:46:24 +01:00
kernitus 20249db1ee fix(shields): apply mixed-mode blocking by defender 2026-05-22 22:19:22 +01:00
kernitus f32671bc02 fix(armour): apply mixed-mode armour strength by defender 2026-05-22 21:56:58 +01:00
kernitus 729c311e6e fix(armour): respect modesets for explosion defence (#861) 2026-05-22 21:05:06 +01:00
kernitus b0f451d7de fix(modesets): correct stale stored world modesets (#865) 2026-05-22 20:13:42 +01:00
kernitus 1eb1d199e2 docs: add commit preparation skill 2026-05-22 19:10:54 +01:00
kernitus f922027474 docs: explain Spotless and pre-commit checks 2026-05-22 19:06:19 +01:00
kernitus 0ac2dbb09b ci: run Spotless checks before builds 2026-05-22 19:00:32 +01:00
kernitus 0eb1a9232d build: add Spotless pre-commit hook 2026-05-22 18:57:43 +01:00
kernitus e335cc60ef build: add Spotless formatting checks 2026-05-22 18:54:56 +01:00
kernitus 224e42c868 docs: add github issue investigation skill 2026-05-22 18:19:12 +01:00
kernitus 7918b92376 docs(agents): clarify Release Please commit subjects 2026-05-22 18:07:01 +01:00
kernitus 8a7c22a988 docs: require concise commit messages 2026-05-22 17:57:45 +01:00
kernitus cebcead148 test: cover legacy default world migration 2026-05-22 17:54:03 +01:00
kernitus 0c4f78b23b docs: explain default world modesets 2026-05-22 17:54:03 +01:00
kernitus 8e9ee90cec feat: migrate default world modesets 2026-05-22 17:54:03 +01:00
kernitus 7d3b41707b feat: support default world modesets 2026-05-22 17:54:03 +01:00
kernitus 53780bc3b2 Merge pull request #858 from iiAhmedYT/master
feat: add per-player module override api
2026-05-22 01:08:28 +01:00
kernitus bc4134bd99 Merge remote-tracking branch 'origin/master' into iiAhmedYT-master 2026-05-22 01:08:05 +01:00
kernitus f6a5c7ae3d test: add java api smoke server task 2026-05-22 01:01:18 +01:00
kernitus 4012f50242 test: package java api smoke plugin 2026-05-22 01:01:18 +01:00
kernitus 140d6a5271 test: cover module override api behaviour 2026-05-22 01:01:18 +01:00
kernitus c6e14bac3d fix: validate module override bulk updates 2026-05-22 01:01:18 +01:00
kernitus e3d9469071 docs: align module override api contract 2026-05-22 01:01:18 +01:00
kernitus c9c26f4d32 chore: bump kotlin version 2026-05-21 20:53:54 +01:00
kernitus 65e28fa61f chore: ignore junit 6 2026-05-21 20:48:23 +01:00
kernitus a82b5fa5c2 Merge branch 'master' of https://github.com/kernitus/BukkitOldCombatMechanics 2026-05-21 20:33:04 +01:00
kernitus 514abf2080 chore: remove junit dep 2026-05-21 20:33:00 +01:00
kernitus ae1b8575bb Merge pull request #887 from kernitus/dependabot/gradle/gradle-wrapper-9.5.1
chore(deps): bump gradle-wrapper from 9.2.1 to 9.5.1
2026-05-21 20:16:34 +01:00
kernitus 57de413447 Merge pull request #884 from kernitus/dependabot/gradle/com.gradleup.shadow-9.4.1
chore(deps): bump com.gradleup.shadow from 9.3.0 to 9.4.1
2026-05-21 20:16:19 +01:00
dependabot[bot] 6782c6b37d chore(deps): bump gradle-wrapper from 9.2.1 to 9.5.1
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.2.1 to 9.5.1.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.2.1...v9.5.1)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 18:57:37 +00:00
dependabot[bot] 0cca53d0bf chore(deps): bump com.gradleup.shadow from 9.3.0 to 9.4.1
Bumps [com.gradleup.shadow](https://github.com/GradleUp/shadow) from 9.3.0 to 9.4.1.
- [Release notes](https://github.com/GradleUp/shadow/releases)
- [Commits](https://github.com/GradleUp/shadow/compare/9.3.0...9.4.1)

---
updated-dependencies:
- dependency-name: com.gradleup.shadow
  dependency-version: 9.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 18:56:08 +00:00
kernitus 3bf236222f Merge pull request #878 from kernitus/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-05-21 19:51:07 +01:00
kernitus cae26e6b7f Merge pull request #879 from kernitus/dependabot/github_actions/googleapis/release-please-action-5
chore(deps): bump googleapis/release-please-action from 4 to 5
2026-05-21 19:50:53 +01:00
kernitus 8c71baf9fe Merge pull request #880 from kernitus/dependabot/gradle/org.mongodb-bson-5.7.0
chore(deps): bump org.mongodb:bson from 5.6.2 to 5.7.0
2026-05-21 19:46:38 +01:00
kernitus 5c252535fe Merge branch 'master' of https://github.com/kernitus/BukkitOldCombatMechanics 2026-05-21 19:31:23 +01:00
kernitus 2e1ef440a9 chore: remove unused adventure api dep 2026-05-21 19:31:16 +01:00
dependabot[bot] fb65c6f65e chore(deps): bump org.mongodb:bson from 5.6.2 to 5.7.0
Bumps [org.mongodb:bson](https://github.com/mongodb/mongo-java-driver) from 5.6.2 to 5.7.0.
- [Release notes](https://github.com/mongodb/mongo-java-driver/releases)
- [Commits](https://github.com/mongodb/mongo-java-driver/compare/r5.6.2...r5.7.0)

---
updated-dependencies:
- dependency-name: org.mongodb:bson
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 17:28:54 +00:00
kernitus b0fdae3a08 Merge pull request #882 from kernitus/dependabot/gradle/me.clip-placeholderapi-2.12.2
chore(deps): bump me.clip:placeholderapi from 2.11.6 to 2.12.2
2026-05-21 18:27:35 +01:00
kernitus 027a5a74dc Merge pull request #883 from kernitus/dependabot/gradle/org.bstats-bstats-bukkit-3.2.1
chore(deps): bump org.bstats:bstats-bukkit from 3.1.0 to 3.2.1
2026-05-21 18:26:50 +01:00
kernitus 0837305721 build(release): add 26.1 Bukkit compatibility metadata 2026-05-21 17:24:39 +01:00
kernitus f9017651b4 build(test): add 26.1.2 to integration matrix 2026-05-21 17:14:56 +01:00
kernitus 8f14b8b8e2 fix(compat): avoid hot-path optional API reflection 2026-05-21 16:57:32 +01:00
kernitus 8fd72f815a test(compat): align integration capability gates 2026-05-21 16:04:53 +01:00
kernitus b5341f3898 fix(compat): clarify semantic version gates 2026-05-21 15:44:10 +01:00
kernitus bfbb2e420f fix(compat): detect API capabilities for version gates 2026-05-21 15:36:53 +01:00
kernitus b12829d7c3 fix(attack-range): detect Paper data component support 2026-05-21 15:24:41 +01:00
kernitus 67e266c938 fix(reflection): compare Minecraft versions lexicographically 2026-05-21 15:17:46 +01:00
dependabot[bot] b89b07ac98 chore(deps): bump org.bstats:bstats-bukkit from 3.1.0 to 3.2.1
Bumps [org.bstats:bstats-bukkit](https://github.com/Bastian/bStats-Metrics) from 3.1.0 to 3.2.1.
- [Release notes](https://github.com/Bastian/bStats-Metrics/releases)
- [Commits](https://github.com/Bastian/bStats-Metrics/compare/v3.1.0...v3.2.1)

---
updated-dependencies:
- dependency-name: org.bstats:bstats-bukkit
  dependency-version: 3.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 13:48:35 +00:00
dependabot[bot] 4ace28e368 chore(deps): bump me.clip:placeholderapi from 2.11.6 to 2.12.2
Bumps me.clip:placeholderapi from 2.11.6 to 2.12.2.

---
updated-dependencies:
- dependency-name: me.clip:placeholderapi
  dependency-version: 2.12.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 13:48:16 +00:00
dependabot[bot] 0dea6da0f7 chore(deps): bump googleapis/release-please-action from 4 to 5
Bumps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) from 4 to 5.
- [Release notes](https://github.com/googleapis/release-please-action/releases)
- [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/release-please-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: googleapis/release-please-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 13:47:13 +00:00
dependabot[bot] 59a5750cd6 chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 13:47:08 +00:00
kernitus 096a35f86d Merge branch 'master' of https://github.com/kernitus/BukkitOldCombatMechanics 2026-05-21 14:46:19 +01:00
kernitus cbb6f7fecf chore(deps): pin netty compatibility baseline 2026-05-21 13:21:39 +01:00
kernitus d30a4a9d7b Merge pull request #870 from kernitus/dependabot/github_actions/gradle/actions-6
chore(deps): bump gradle/actions from 4 to 6
2026-05-21 12:05:09 +01:00
kernitus 4f4f8b890a Merge pull request #875 from kernitus/dependabot/gradle/org.jetbrains.kotlin-kotlin-reflect-2.3.21
chore(deps): bump org.jetbrains.kotlin:kotlin-reflect from 2.3.0 to 2.3.21
2026-05-19 01:43:13 +01:00
kernitus 1bb6776ed9 Merge pull request #874 from kernitus/dependabot/gradle/org.jetbrains.kotlin-kotlin-test-2.3.21
chore(deps): bump org.jetbrains.kotlin:kotlin-test from 2.3.0 to 2.3.21
2026-05-19 01:43:08 +01:00
kernitus dfaaefa579 Merge pull request #877 from kernitus/dependabot/gradle/org.jetbrains.kotlin-kotlin-stdlib-jdk8-2.3.21
chore(deps): bump org.jetbrains.kotlin:kotlin-stdlib-jdk8 from 2.3.0 to 2.3.21
2026-05-19 01:43:01 +01:00
kernitus 7f040dca33 Merge pull request #869 from kernitus/dependabot/github_actions/softprops/action-gh-release-3
chore(deps): bump softprops/action-gh-release from 2 to 3
2026-05-19 01:41:09 +01:00
kernitus f3372b11e7 Merge pull request #873 from kernitus/dependabot/github_actions/actions/github-script-9
chore(deps): bump actions/github-script from 8 to 9
2026-05-19 01:40:44 +01:00
kernitus 00f0efe865 Merge pull request #872 from kernitus/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-05-19 01:40:28 +01:00
kernitus aba5d1d2f3 Merge pull request #868 from kernitus/dependabot/github_actions/actions/setup-java-5
chore(deps): bump actions/setup-java from 4 to 5
2026-05-19 01:40:10 +01:00
dependabot[bot] 8e18ac385b chore(deps): bump org.jetbrains.kotlin:kotlin-stdlib-jdk8
Bumps [org.jetbrains.kotlin:kotlin-stdlib-jdk8](https://github.com/JetBrains/kotlin) from 2.3.0 to 2.3.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.0...v2.3.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-stdlib-jdk8
  dependency-version: 2.3.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:30:18 +00:00
dependabot[bot] 9d09401374 chore(deps): bump org.jetbrains.kotlin:kotlin-reflect
Bumps [org.jetbrains.kotlin:kotlin-reflect](https://github.com/JetBrains/kotlin) from 2.3.0 to 2.3.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.0...v2.3.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-reflect
  dependency-version: 2.3.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:29:02 +00:00
dependabot[bot] b84e41ddd0 chore(deps): bump org.jetbrains.kotlin:kotlin-test from 2.3.0 to 2.3.21
Bumps [org.jetbrains.kotlin:kotlin-test](https://github.com/JetBrains/kotlin) from 2.3.0 to 2.3.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.0...v2.3.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-test
  dependency-version: 2.3.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:27:55 +00:00
dependabot[bot] b4b791150f chore(deps): bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:26:52 +00:00
dependabot[bot] fff3580cf6 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:26:48 +00:00
dependabot[bot] fd3fca9b11 chore(deps): bump gradle/actions from 4 to 6
Bumps [gradle/actions](https://github.com/gradle/actions) from 4 to 6.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v4...v6)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:26:45 +00:00
dependabot[bot] 125a902bc3 chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:26:41 +00:00
dependabot[bot] 5d74faa534 chore(deps): bump actions/setup-java from 4 to 5
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 00:26:38 +00:00
kernitus 0d21867838 ci: add dependabot for deps 2026-05-19 01:26:06 +01:00
kernitus 4993f7e9ac docs: mythicmobs compat 2026-05-19 01:23:03 +01:00
kernitus a25e0ed24c fix: respect MythicMobs no-knockback damage (#860) 2026-05-19 01:07:28 +01:00
kernitus 3c3bc1e26c test(integration): cover bypasses_cooldown damage types 2026-05-19 01:01:52 +01:00
kernitus 89dfc22092 docs(agents): clarify integration log access 2026-05-19 01:01:17 +01:00
kernitus 83a7154d8c fix(damage): respect bypasses_cooldown damage types #864 2026-05-19 00:08:49 +01:00
kernitus 7627b24863 docs: add repo-local agent workflow guidance 2026-05-18 22:49:23 +01:00
kernitus c228809ac9 chore: bump XSeries to support >=26.1.1
fixes #862
2026-05-18 21:54:52 +01:00
kernitus 83c08b5ff5 chore: bump packetvents #862 2026-05-07 18:24:30 +01:00
NotAlexNoyle bd88c2bc93 Fork-specific changes 2026-04-22 12:09:33 -04:00
iiAhmedYT d4eee15c54 feat: rewrite in kotlin 2026-03-31 10:04:36 +02:00
kernitus 7edb0a525d docs: update contibuting.md 2026-03-23 16:59:18 +00:00
kernitus af45cff7b7 chore: bump version 2026-03-23 16:55:37 +00:00
kernitus 890c41f442 feat: per-item cooldown
closes #856
2026-03-23 16:53:10 +00:00
iiAhmedYT a091b06889 feat: improve API & introduce API.md 2026-03-23 14:43:28 +02:00
iiAhmedYT c2043e23e0 feat: add per-player module override api 2026-03-21 17:40:59 +02:00
kernitus 081741cf36 ci: remove debug step 2026-03-20 17:13:30 +00:00
kernitus b194693853 build: fix bukkit versions 2026-03-20 17:07:46 +00:00
kernitus 561b9fc6cf build: debug curseforge versions 2026-03-20 17:01:54 +00:00
kernitus e1e5532817 build: debug curseforge versions 2026-03-20 16:58:00 +00:00
kernitus b4bcaac897 Merge pull request #846 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.4.0
2026-03-20 16:28:55 +00:00
kernitus 44fe7585a1 chore(master): release 2.4.0 2026-03-08 18:46:27 +00:00
kernitus 5d3887d5b1 feat: togglable paper sword blocking 2026-03-08 18:45:55 +00:00
kernitus ad05bd2916 refactor(reflection): remove unused ClassType helper 2026-02-26 15:41:42 +00:00
kernitus f3833406e4 fix(reflection): tighten chooser compatibility fallback 2026-02-26 15:27:39 +00:00
kernitus 7817d9c1da test(integration): harden melee damage assertions 2026-02-26 14:40:28 +00:00
kernitus cc0c2a51df refactor: improve sword-blocking entity click dedupe 2026-02-26 13:41:22 +00:00
kernitus f0a71eeed7 refactor: remove old java tester code 2026-02-26 12:05:41 +00:00
kernitus 2bb730e5c2 fix(sword-blocking): harden legacy death shield drop reconciliation 2026-02-26 11:25:00 +00:00
kernitus 96d698145e fix(sword-blocking): sweep stale consumable state on join/quit/world 2026-02-25 18:08:08 +00:00
kernitus a92664d293 fix(inventory): harden stale deferred item mutation paths 2026-02-25 16:33:17 +00:00
kernitus 8889bfeb46 fix(sword-blocking): harden inventory fail-safes to prevent ghosting 2026-02-25 12:21:24 +00:00
kernitus 727fa97b4e fix(sword-blocking): prevent legacy fallback from cancelling unrelated shield interactions 2026-02-10 16:23:14 +00:00
kernitus cfc596c3d6 fix(sword-blocking): prevent GUI click/drag item rewrites & fallback unknown clients to shield 2026-02-10 15:42:40 +00:00
kernitus 68073e876d fix(sword-blocking): restore offhand item for pre-1.20.5 fallback clients 2026-02-10 14:27:32 +00:00
kernitus a2609191a6 Merge pull request #852 from x9nico/master
Update PacketEvents dependency version to 2.11.2 (Release)
2026-02-10 11:05:19 +00:00
Nicolas RAYNAUD 15d56e20b0 Update PacketEvents dependency version to 2.11.2 (Release) 2026-02-09 19:18:21 +01:00
kernitus 388bee5731 fix(sword-blocking): fall back to shield for pre-1.20.5 clients
fixes #842
2026-01-28 00:32:52 +00:00
kernitus b887a28a95 fix(sword-blocking): clear sword consumable components on reload when disabled
fixes #845
2026-01-27 21:46:25 +00:00
kernitus 4624ac0c42 fix: strip sword consumable component 2026-01-27 18:02:03 +00:00
kernitus 432385320c fix: don't overwrite swords on every click #843 2026-01-27 17:46:12 +00:00
kernitus afdf309265 Merge branch 'master' of https://github.com/kernitus/BukkitOldCombatMechanics 2026-01-24 16:57:44 +00:00
kernitus f484f12b5f build: fix hangar api token name 2026-01-24 16:57:36 +00:00
kernitus 3b095d70ff Merge pull request #831 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.3.0
2026-01-24 16:05:52 +00:00
kernitus 8fd5a10bd4 chore(master): release 2.3.0 2026-01-24 15:35:25 +00:00
kernitus 10ff4ceead fix: reflection error on weapon enchant
closes #840
2026-01-24 15:34:37 +00:00
kernitus e01faea183 fix: only strip consumable on swords
fixes #841
2026-01-24 14:03:18 +00:00
kernitus 3198f1899e chore: only warn for custom enchants #840 2026-01-20 00:00:14 +00:00
kernitus 5899937aaf test(1.9): use embedded channel 2026-01-14 23:34:09 +01:00
kernitus 44afce1a0f feat: switch from protocollib to packetevents
closes #790
2026-01-14 23:10:33 +01:00
kernitus ebdfd101ae fix: clear modules list on reload 2026-01-14 13:07:05 +01:00
kernitus 0ca855a216 feat: item damage lore
closes #775
2026-01-13 15:51:48 +01:00
kernitus 4be1c20fb0 test: fix sword blocking animation 2026-01-12 23:58:09 +01:00
kernitus 83ff726c5a perf(disable-enderpearl-cooldown): use HashMap and lazily drop expired cooldown entries 2026-01-12 21:16:14 +01:00
kernitus c9563b0578 test: enderpearl module 2026-01-12 20:32:40 +01:00
kernitus 4f8df3c5d0 perf(old-player-regen): switch to tick-based interval tracking with shared counter task 2026-01-12 19:44:33 +01:00
kernitus 1fc05721bf test: player regen 2026-01-12 19:12:47 +01:00
kernitus c7932d6f0b perf(old-armour-durability): replace per-explosion suppression task with shared 1-tick expiry cleaner 2026-01-12 18:42:01 +01:00
kernitus a18b1efec0 perf(shield-damage-reduction): replace per-hit fully-blocked cleanup tasks with shared 1-tick expiry cleaner 2026-01-12 18:17:15 +01:00
kernitus 9667ef539e perf(player-knockback): replace per-hit cleanup tasks with shared 1-tick expiry cleaner 2026-01-12 17:44:06 +01:00
kernitus b605501b40 perf(attack-cooldown-tracker): gate sampler by API presence and use HashMap for stable caching 2026-01-12 16:52:44 +01:00
kernitus e8f28b366c test: attack cooldown tracker 2026-01-12 16:43:01 +01:00
kernitus 5856bed518 perf(fishing-rod-velocity): replace per-hook gravity tasks with single shared tick runner 2026-01-12 16:29:42 +01:00
kernitus d30a6ff244 test: fishing rod velocity 2026-01-12 16:20:51 +01:00
kernitus a08b5b47bc perf(sword-block): reduce amount of recurring tasks 2026-01-12 15:27:59 +01:00
kernitus 2825b35b4c fix: legacy (pre-1.11) sweep detection 2026-01-12 14:39:31 +01:00
kernitus 8596c9da58 feat: sword blocking animation #769 2026-01-12 13:29:03 +01:00
kernitus 37528722dd test: fix 1.9 tests 2026-01-11 21:23:17 +01:00
kernitus d3dcf27bc2 test: improve 1.12 2026-01-11 19:45:33 +01:00
kernitus c85914f562 test: improve 1.9 harness 2026-01-11 18:28:28 +01:00
kernitus f8d5648493 test: improve 1.9 harness 2026-01-11 18:04:40 +01:00
kernitus d2f6af98f6 test: improve 1.9 harness 2026-01-11 17:26:07 +01:00
kernitus f763d380a4 test: 1.9 & curseforge uploader action 2026-01-11 17:14:12 +01:00
kernitus 90e61197b0 chore: server software & enabled modules charts 2026-01-10 16:45:50 +01:00
kernitus 131fbc0f9b docs: improve readme [skip ci] 2026-01-10 16:21:34 +01:00
kernitus 5138f63526 docs: improve readme [skip ci] 2026-01-10 16:20:12 +01:00
kernitus d0c3d2ce82 docs: improve readme [skip ci] 2026-01-10 16:15:26 +01:00
kernitus 9ea7641a12 docs: improve readme [skip ci] 2026-01-10 16:13:47 +01:00
kernitus 143f451205 docs: improve readme [skip ci] 2026-01-10 16:09:57 +01:00
kernitus 481c30eb56 docs: improve readme 2026-01-10 16:07:04 +01:00
kernitus 5cbd93d89d feat: 1.8 hitbox
closes #69
2026-01-10 14:52:18 +01:00
kernitus a623bce5e1 Merge branch 'kotlin-tests' 2026-01-09 23:27:40 +01:00
kernitus be70c5bb77 feat: custom trident & mace damage
closes #757
2026-01-09 23:27:31 +01:00
kernitus cbef70791e Merge branch 'kotlin-tests' 2026-01-09 21:47:17 +01:00
kernitus bba1ecb62e fix: chorus fruit tp into blocks
fixes #748
2026-01-09 21:47:03 +01:00
kernitus 207f80230e Merge branch 'kotlin-tests' 2026-01-09 21:09:57 +01:00
kernitus cfa1e58b5b fix: skip unknown sound packets
fixes #828 & #826
2026-01-09 21:09:45 +01:00
kernitus bb9afdea2d Merge branch 'kotlin-tests' 2026-01-09 20:59:06 +01:00
kernitus 1408720089 fix: unknown particles.
fixes #825
2026-01-09 20:58:50 +01:00
kernitus f96eae293c test: weapon durability 2026-01-09 20:16:43 +01:00
kernitus 2f23db64be Merge branch 'kotlin-tests' 2026-01-09 16:22:57 +01:00
kernitus 8bcb3956f4 docs: limitations for custom mob weapons 2026-01-09 16:22:12 +01:00
kernitus 91b121ff00 fix: apply old tool damage to all mobs
fixes #735
2026-01-09 16:16:53 +01:00
kernitus 54c432f5d7 Merge branch 'kotlin-tests' 2026-01-09 13:54:29 +01:00
kernitus 4cecb649c8 fix: double strength effect when old-potion-effects disabled
fixes #781
2026-01-09 13:54:15 +01:00
kernitus 5e13a5fb7b test: make EDBEL map ttl monotonic 2026-01-09 01:01:09 +01:00
kernitus 11ec51bd60 perf: use one task to clear EDBEE map 2026-01-09 00:50:10 +01:00
kernitus 2339c2e59c test: fix critical hit tests 2026-01-09 00:07:11 +01:00
kernitus 7a0171340c test: more fire 2026-01-08 22:18:26 +01:00
kernitus a656644bdb Merge branch 'kotlin-tests' 2026-01-08 21:13:02 +01:00
kernitus 9af8a0fa79 fix: fire damage overwriting lastDamage
Fixes #707
2026-01-08 21:12:52 +01:00
kernitus ee1081ad00 test: environmental damage 2026-01-08 18:27:09 +01:00
kernitus 1256c918a3 Merge branch 'kotlin-tests' 2026-01-08 13:54:29 +01:00
kernitus 3dc8aa9750 test: fire aspect player attack #707 2026-01-08 13:54:05 +01:00
kernitus 5c7ef8a27f test: fix config disabling 2026-01-08 00:03:08 +01:00
kernitus cf96d8b9a8 Merge branch 'kotlin-tests' 2026-01-07 23:37:24 +01:00
kernitus 1321717288 fix: negative last damage
fixes #765
2026-01-07 23:36:32 +01:00
kernitus 5f7bf12741 feat: always & disabled modules lists 2026-01-07 21:02:11 +00:00
kernitus 18dd6e17ae feat: always & disabled modules lists 2026-01-07 21:59:46 +01:00
kernitus ee71088e2f Merge branch 'master' into kotlin-tests 2026-01-07 18:55:25 +01:00
kernitus 07159623f9 test: disable offhand 2026-01-07 18:55:00 +01:00
kernitus 54aaf0c1a0 Merge pull request #836 from Hylex-Survival/pr/fix-disable-offhand
fix: `disable-offhand` handling on modeset change
2026-01-07 17:54:44 +00:00
Ítalo Seara ecca0b56d6 fix: 'disable-offhand' module working even if disabled 2026-01-05 10:52:24 -03:00
kernitus 161bbc14db test: fire aspect 2025-12-28 21:52:22 +01:00
kernitus 0b25fe7af9 test: attack invuln test 2025-12-28 17:45:39 +01:00
kernitus d139e09831 test: modified weapon damage crit 2025-12-28 16:59:03 +01:00
kernitus 5014c805b9 test: critical hits 2025-12-28 16:31:05 +01:00
kernitus 0692dbbed6 docs: update readme 2025-12-28 15:59:57 +01:00
kernitus 0e979bc56c chore: enable offhand by default 2025-12-28 15:56:03 +01:00
kernitus e9039523a5 test: make gapple test deterministic 2025-12-28 15:47:44 +01:00
kernitus c547cc2c7d test: 1.12 fix failing overdamage test 2025-12-28 15:42:08 +01:00
kernitus 5ddf03171d test: make overdamage test expect reality 2025-12-28 15:29:45 +01:00
kernitus 1e4a59ee02 test: disable double hit test for now 2025-12-28 15:20:28 +01:00
kernitus e361578ff6 test: don't stub 1.12 attacks 2025-12-28 15:12:09 +01:00
kernitus 06753f1b71 Merge pull request #834 from kernitus/kotlin-tests
Kotlin tests
2025-12-28 12:37:29 +00:00
kernitus b8dcf4282e test: apply attack attributes in 1.12 2025-12-28 13:37:10 +01:00
kernitus ab5eacb745 test: run on 1.12 2025-12-28 11:53:28 +01:00
kernitus d1c905d7af refactor: feature gate potion weakness compensation 2025-12-28 11:31:48 +01:00
kernitus d86601504e fix: weakness calculations for amplifier > 1 2025-12-28 01:09:28 +01:00
kernitus f502adbbec fix: weakness calc on >=1.20 2025-12-28 00:23:44 +01:00
kernitus 66b465c092 test: add overdamage tests 2025-12-27 21:50:40 +01:00
kernitus 16d4eeabff test: weakness overdamage 2025-12-27 19:53:42 +01:00
kernitus d5053b1742 test: use xseries in tests 2025-12-27 18:34:32 +01:00
kernitus c20937a8ad Merge branch 'master' into kotlin-tests 2025-12-27 17:51:04 +01:00
kernitus a46d54b41a test: gapple & potions & knockback & durability & sweep 2025-12-27 17:49:24 +01:00
kernitus 977f118475 test: downgrade kotest to 5.x to use java 8 2025-12-27 16:00:23 +01:00
kernitus 8023c3d574 ci: skip issue wrapping if body not changed [skip ci] 2025-12-27 14:20:19 +01:00
kernitus 88f04efe67 ci: fix issue syntax [skip ci] 2025-12-27 14:16:33 +01:00
kernitus ac0bfcebf5 ci: fix issue syntax [skip ci] 2025-12-27 14:12:53 +01:00
kernitus 64eb5b1baf ci: remove issue titles 2025-12-27 14:10:00 +01:00
kernitus cdebc3b7ce ci: github issues wrap 2025-12-27 14:08:35 +01:00
kernitus a4db0b071e Merge branch 'master' into kotlin-tests 2025-12-27 00:18:04 +01:00
kernitus ec4ba22d97 build: update changelog commit categories 2025-12-27 00:17:35 +01:00
kernitus 942672a54d chore: bump version to 2.3.0-beta 2025-12-27 00:09:08 +01:00
kernitus fa828d3469 feat: warn on unknown effects, enchants, etc 2025-12-27 00:07:52 +01:00
kernitus 945313618a refactor: Use XSeries instead of compat utils 2025-12-26 23:39:14 +01:00
kernitus 09effedf13 Merge pull request #830 from kernitus/kotlin-tests
Kotlin tests
2025-12-26 21:37:42 +00:00
kernitus 47ffa963ec feat: copper tools support #822 #823 2025-12-26 22:25:56 +01:00
kernitus b8f0d2900f test: skip old version sleep 2025-12-26 20:35:33 +01:00
kernitus 3e4b6ce9e1 test: 1.12 integration tests 2025-12-26 20:15:21 +01:00
kernitus edbfef4e66 test: undeprecate 2025-12-26 18:04:38 +01:00
kernitus 6e7505f9ef build: update all deps 2025-12-26 17:58:18 +01:00
kernitus 63447dc044 test: suppress silly warnings 2025-12-26 16:36:14 +01:00
kernitus 46d88581d8 test: fix double add & remove players 2025-12-26 16:31:36 +01:00
kernitus 10183dea2f build: gradle 9 2025-12-26 16:17:07 +01:00
kernitus d1527d3dc0 test: paper version matrix 2025-12-26 15:34:17 +01:00
kernitus c63c940fcc feat: kotlin integration tests 2025-12-26 14:35:47 +01:00
kernitus b6ad7d1c48 Merge pull request #820 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.2.0
2025-10-29 13:00:22 +00:00
kernitus 3c3d0205ce perf: memoise often used reflections 2025-10-14 17:05:28 +01:00
kernitus 11e787b000 perf: use reflection caching in sounds module #815 2025-10-14 16:38:28 +01:00
kernitus c2749dec17 chore(master): release 2.2.0 2025-10-14 16:14:56 +01:00
kernitus 1592dc2498 feat: add fallback sound reflection logic
Fixes #814
2025-10-14 16:14:33 +01:00
kernitus 5927729ea5 docs: update issue templates 2025-10-14 16:01:28 +01:00
kernitus fc51924504 docs: use yaml issue forms 2025-10-14 15:57:06 +01:00
kernitus 09fd3c556a docs: update issue templates 2025-10-14 15:52:30 +01:00
kernitus e2f0369f29 fix: ocm mode permissions
Fixes #818
2025-10-14 15:30:24 +01:00
kernitus 8d88a49d0f fix: update checker versioning logic 2025-10-14 14:42:45 +01:00
kernitus 462e536628 fix: use reflection for inventory view
Fixes #812
2025-10-14 14:23:37 +01:00
kernitus 4673b43b1e Merge pull request #811 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-22 00:29:41 +02:00
kernitus f0abdd382c chore(master): release 2.1.0 2025-08-22 00:27:30 +02:00
kernitus a6ea7a26b5 build: update gradle action 2025-08-22 00:27:09 +02:00
kernitus 9291ccbcaf Merge pull request #810 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-22 00:22:00 +02:00
kernitus af1d1931f7 chore(master): release 2.1.0 2025-08-22 00:20:48 +02:00
kernitus 015dd30848 build: retrigger release 2025-08-22 00:20:20 +02:00
kernitus d23a14459e Merge pull request #809 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-22 00:09:57 +02:00
kernitus 573b769b99 chore(master): release 2.1.0 2025-08-22 00:07:25 +02:00
kernitus dab0b4f978 build: retrigger release 2025-08-22 00:07:05 +02:00
kernitus e969e18e10 Merge pull request #808 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-21 23:59:36 +02:00
kernitus 43aedbbe3a chore(master): release 2.1.0 2025-08-21 23:58:14 +02:00
kernitus 1cac49a2f0 build: retrigger release 2025-08-21 23:57:52 +02:00
kernitus 8fc34f9805 build: use new release action 2025-08-21 23:56:03 +02:00
kernitus 00b740f4e5 Merge pull request #807 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-21 23:49:17 +02:00
kernitus 96df667470 chore(master): release 2.1.0 2025-08-21 23:45:35 +02:00
kernitus 44e47b7489 build: retrigger release 2025-08-21 23:45:15 +02:00
kernitus 32f3b48c60 build: add permissions to build and release 2025-08-21 23:42:21 +02:00
kernitus 3c01ad336e Merge pull request #806 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.1.0
2025-08-21 23:33:22 +02:00
kernitus eeff170c7a chore(master): release 2.1.0 2025-08-21 23:32:02 +02:00
kernitus e72837462f feat: make config upgrader remove deprecated keys 2025-08-21 23:31:19 +02:00
kernitus 68c51ab880 feat: compat with 1.21.8 enums 2025-08-21 23:14:14 +02:00
kernitus d119c9f35e refactor: improve fishing knockback cross-version compat 2025-08-21 22:50:50 +02:00
kernitus 31e3877330 fix: check fishing knockback module enabled when reeling in
Fixes #803
2025-08-21 22:34:48 +02:00
kernitus b355322f9b fix: disable attack sounds not working in >1.21
Fixes #794
2025-08-21 22:25:34 +02:00
kernitus 4ef28fb7bd fix: improve sound packet compatibility
Fixes #780
2025-08-21 21:53:39 +02:00
kernitus b2379cc17e fix: ExceptionInInitialiserError in enchantment compat
Fixes #782
2025-08-21 19:32:14 +02:00
kernitus b30e27ddb3 fix: null pointer in potion compat
Fixes #791
2025-08-21 19:25:34 +02:00
kernitus 95c9446edb fix: unmentioned worlds modesets not allowed #792 2025-08-21 19:16:23 +02:00
kernitus aa6d908af6 chore: add gapple module to new modeset by default 2025-08-21 19:12:15 +02:00
kernitus 7d78a9b0d0 chore: upgrade gradle to 8.14.3 2025-08-21 18:08:08 +02:00
kernitus da3d5f0b28 feat: remove deprecated modules 2025-08-21 17:57:11 +02:00
kernitus c7ebb094bb chore: update readme 2025-08-21 17:40:30 +02:00
kernitus 35fe52505b build: don't publish release to hangar in dev build job 2024-10-28 17:28:30 +00:00
kernitus 468a126066 build: use release please PAT 2024-10-28 17:08:10 +00:00
kernitus f75448675e build: build-upload-release.yml 2024-10-28 17:07:35 +00:00
kernitus bf3077be04 Merge pull request #762 from kernitus/release-please--branches--master--components--OldCombatMechanics
chore(master): release 2.0.4
2024-10-28 16:42:25 +00:00
kernitus cd280ac7a4 Update CHANGELOG.md 2024-10-28 16:39:56 +00:00
github-actions[bot] 00edc7963d chore(master): release 2.0.4 2024-10-28 16:25:17 +00:00
kernitus f5b59d7537 fix: listen to dynamically loaded worlds for modesets
Fixes #747
2024-10-28 16:24:38 +00:00
kernitus 822fb1fa14 fix: avoid ghost items after sword block restore
Fixes #749
2024-10-28 15:38:51 +00:00
kernitus b299df2d21 fix: don't prevent moving shield to chests in disable offhand module 2024-10-28 15:21:57 +00:00
kernitus 8aa3fa3308 fix: sword blocking shield ending up in inv on world change
Fixes #753
2024-10-28 14:00:14 +00:00
kernitus cbc0c4bc8b fix: error when right clicking air while holding block in <1.13
Closes #754
2024-10-28 13:32:05 +00:00
kernitus 07106e61a2 fix: resolve elytras always unequipped by removing out-of-scope module
Closes #725
2024-10-26 13:19:48 +01:00
kernitus 556543f516 docs: update README.md 2024-10-20 19:44:24 +01:00
kernitus bd38b9274e build: hangar api key constistent name 2024-10-20 19:29:56 +01:00
kernitus 684e481e8d build: set paper version at top level 2024-10-20 19:23:28 +01:00
kernitus c32540f1ce build: add missing gradle import 2024-10-20 19:05:48 +01:00
kernitus 04e76fe6c3 build: publish dev builds to hangar 2024-10-20 19:04:21 +01:00
kernitus 9d80765bb9 build: add missing import 2024-10-20 18:45:46 +01:00
kernitus 90a1506ff7 build: fix variable name in build.gradle.kts 2024-10-20 18:44:28 +01:00
kernitus f05edcb096 chore: add support for publishing to papermc hangar 2024-10-20 18:42:24 +01:00
kernitus e1f3329c0a fix: bootstrap manifest config 2024-10-20 18:08:31 +01:00
kernitus 56dd66a4bf fix: return of the packages 2024-10-20 18:00:20 +01:00
kernitus 1540902c6b fix: create release-please-manifest.json 2024-10-20 17:56:17 +01:00
kernitus f23bbe4fd1 fix: add release please manifest 2024-10-20 17:55:28 +01:00
kernitus 59ef76fb9d fix: don't use packages option 2024-10-20 17:52:29 +01:00
kernitus 5d98ed36d6 fix: use separate pull requests 2024-10-20 17:48:02 +01:00
kernitus 7de3ecd4b5 fix: don't use packages config for release please 2024-10-20 17:44:57 +01:00
kernitus b92e7649a5 fix: move release please config 2024-10-20 17:41:32 +01:00
kernitus 534f5cd2ee fix: change release please config path 2024-10-20 17:39:59 +01:00
kernitus 5780737e6b fix: use valid release please config 2024-10-20 16:58:34 +01:00
kernitus c6cecf3b29 fix: specify release please config 2024-10-20 16:55:32 +01:00
kernitus e3260026ee fix: follow release please config schema more closely 2024-10-20 16:51:13 +01:00
kernitus d462156e39 fix: move release please config 2024-10-20 16:35:25 +01:00
kernitus a5baba0faf chore: don't specify release please path 2024-10-20 16:32:47 +01:00
kernitus cb3ece93c7 chore: update release please config 2024-10-20 16:09:41 +01:00
kernitus aa83c729cd fix: don't use block comment for version annotation 2024-10-20 15:48:57 +01:00
kernitus 5dd99473b7 fix: don't use regex for updating version 2024-10-20 15:40:34 +01:00
kernitus 317b58c360 fix: update release please regex 2024-10-20 15:24:17 +01:00
kernitus fd92debc10 fix: remove chores from release please config 2024-10-20 15:17:52 +01:00
kernitus ddcc79088b chore: update release-please configuration 2024-10-20 15:14:08 +01:00
kernitus 4c883866be chore: annotate build gradle for please 2024-10-20 15:05:52 +01:00
kernitus 379523b0f9 fix: please 2024-10-20 15:05:21 +01:00
kernitus 4c8a09872a fix: use java release-type for release please 2024-10-20 14:45:02 +01:00
kernitus 8e39242c70 chore: add release please config 2024-10-20 14:43:59 +01:00
kernitus 3b04796c8a fix: use release please config 2024-10-20 14:42:19 +01:00
kernitus 09444594ae fix: force first release 2024-10-20 14:34:36 +01:00
kernitus 61c3155d1c docs: update README 2024-10-20 14:11:34 +01:00
kernitus 42fdee7721 Update release-please.yml 2024-10-20 14:04:58 +01:00
kernitus 869f4237fd Create release-please.yml 2024-10-20 14:02:58 +01:00
kernitus 2608fdaef3 Create build-upload-release.yml 2024-10-20 13:45:44 +01:00
kernitus f9c538f2ce Bump version to beta 2024-10-05 14:17:00 +01:00
kernitus c93a6af16e Remove debug log. Fixes #755 2024-10-05 14:15:51 +01:00
kernitus 954cc3204c Bump version to 2.0.3 2024-09-21 15:31:27 +01:00
kernitus 431198e64b Fix loading reflector on 1.21 2024-06-20 20:24:33 +01:00
kernitus 300efd61da Merge pull request #737 from SrBedrock/fix-736
GH-736 - Fix empty message with prefix
2024-06-10 16:14:37 +01:00
ThiagoROX 221c619aa3 GH-736 - Fix empty message with prefix 2024-06-08 18:42:52 -03:00
kernitus 7b2142a3b1 Update README.md 2024-06-03 18:55:03 +01:00
kernitus 526b819a1a Rename papermc.png to paper.png 2024-06-03 18:54:47 +01:00
kernitus 222165df74 Add files via upload 2024-06-03 18:50:36 +01:00
kernitus b55168d76d Update README.md 2024-06-03 18:45:59 +01:00
kernitus 8bf94b8d6c Add PaperMC logo file 2024-06-03 18:44:37 +01:00
kernitus 1fc6c8cde2 Remove vestiges of spartan api 2024-06-03 18:39:22 +01:00
kernitus f744ff1f7d Update README.md 2024-06-03 17:56:17 +01:00
kernitus a1f7ba7cce Bump version to 2.0.2 for release 2024-06-03 17:48:21 +01:00
kernitus fd9df6379e Add compat for new enchantment names 2024-06-03 16:27:47 +01:00
kernitus 204980a6df Add compat for fishing bobber enum 2024-06-03 15:58:35 +01:00
kernitus 7f15383a06 Update gradle.yml 2024-06-03 15:30:20 +01:00
kernitus b385f487b4 Update gradle.yml 2024-06-03 15:27:01 +01:00
kernitus 291c3560fa Remove broken spartanapi dependency 2024-06-03 13:40:52 +01:00
kernitus bda02c73c9 Use class instead of enum for potion type compat 2024-06-03 13:39:35 +01:00
kernitus 1400051d31 Add potion type compatibility layer 2024-06-03 00:12:00 +01:00
kernitus 45ec9fa4b9 Refactor ModuleGoldenApple 2024-06-02 19:25:23 +01:00
kernitus aea9c63869 Fix gapple effects overriding shorter effects of same amplifier 2024-06-02 19:22:41 +01:00
kernitus 41ad033df4 Use new potion names in config for gapples module 2024-06-02 18:48:56 +01:00
kernitus 68941fc238 Fix gapples module for old & new potion effect names 2024-06-02 17:24:58 +01:00
kernitus 76fbcc96c1 Add compatibility with new PotionEffectType 2024-06-02 16:28:37 +01:00
kernitus 649f50ba92 Remove unused packet classes 2024-05-25 12:43:25 +01:00
kernitus ea563c1f7e Add 1.20.5/6 compatibility. Fixes #728 2024-04-30 23:42:28 +01:00
kernitus 74af8762c6 Get player on block build for sword blocking <1.13. Fixes #719 2024-04-27 12:31:45 +01:00
kernitus dab1e024aa Add more debugging to attack cooldown module 2024-03-16 20:14:13 +00:00
kernitus a196e6ec93 Simplify attack cooldown module 2024-03-16 20:08:26 +00:00
kernitus e6b94b14ae Disable in-game testing 2024-03-16 17:18:44 +00:00
kernitus 8dc8986320 Update new damage after accounting for overdamage 2024-03-16 17:14:45 +00:00
kernitus 3b9d50bd4c Check for fake overdamage from environmental sources 2024-03-16 17:13:02 +00:00
kernitus b47a26cf3f Update dependencies 2024-03-16 14:38:25 +00:00
kernitus 8fd0e0fa02 Merge branch 'master' into ingametesting
# Conflicts:
#	build.gradle.kts
#	src/main/java/kernitus/plugin/OldCombatMechanics/OCMMain.java
2024-03-16 14:32:34 +00:00
kernitus 976d31fa03 Merge pull request #724 from Waischbrot/patch-1
fix NullPointer when sending custom sounds
2024-03-09 20:01:55 +00:00
Lukas ʕ•́ᴥ•̀ʔ 11ee79af73 fix NullPointer when sending custom sounds
Texturepacks allow for sending custom sounds. But when sending custom sounds, the line...
final String soundName = sound.toString();
...will throw a NullPointer due to the sound not being present in the Sound Enumeration. This check basicly just prevents this exception from happening.
2024-03-04 21:10:33 +01:00
kernitus 3358fcea6d Remove extraneous number in plugin.yml. Fixes #717 2024-02-26 00:22:44 +00:00
kernitus e732739308 Improve modeset explanation 2024-02-13 19:10:52 +00:00
kernitus 226326875c Merge pull request #715 from ArmaRealms/dev/705
Don't send empty messages
2024-02-13 19:00:43 +00:00
kernitus 8d99b60cac Downgrade Spigot dependency to 1.20.3 for Java 8 compatibility 2024-02-13 18:58:00 +00:00
ThiagoROX c3c4d4b207 Added comments to config.yml 2024-02-13 01:05:12 -03:00
ThiagoROX a24d6c14ff Don't send empty string
Close #705
2024-02-12 19:55:45 -03:00
kernitus fdc1073f26 Update README.md 2024-01-12 20:01:16 +00:00
kernitus fab515b076 Update README.md 2024-01-12 19:57:03 +00:00
kernitus ffffaaf564 Set version to 2.0.1 for release 2024-01-12 19:53:02 +00:00
kernitus f3877968c3 Add enderpearl cooldown placeholder support 2024-01-12 19:52:09 +00:00
kernitus 431ca863e7 Add gapple placeholder support 2024-01-12 19:43:38 +00:00
kernitus d310c4b9ec Fix error when world not in config. #702 2024-01-09 11:33:47 +01:00
kernitus 2071fb0d75 Return empty list if world not found in tab complete. #702 2024-01-08 20:43:47 +01:00
kernitus a01a746555 Merge remote-tracking branch 'origin/master' 2024-01-08 18:19:18 +01:00
kernitus 573a20079a Properly implement mode.own permission 2024-01-08 18:19:09 +01:00
kernitus bbd4add1c3 Update README.md 2024-01-08 18:09:10 +01:00
kernitus e30298b97a Update README.md 2024-01-08 17:45:55 +01:00
kernitus 8bb5d8624b Bump version to 2.0.0 2024-01-08 16:28:07 +01:00
kernitus 625e722d46 Make no protocollib message more informative 2024-01-08 15:56:40 +01:00
kernitus 6c82990a05 Re-enable testing 2024-01-08 15:54:17 +01:00
kernitus 924dc7b4de Comment gradle config 2024-01-08 15:34:25 +01:00
kernitus ecfc6d5801 Rearrange default config 2024-01-08 14:19:50 +01:00
kernitus 39ca1c0c0e Bump PlaceholderAPI version 2024-01-08 13:52:26 +01:00
kernitus fd5a53b464 Disable features when no ProtocolLib & fix sounds for 1.9 2024-01-07 19:38:58 +01:00
kernitus 7dc4ef8865 Use ProtocolLib for sword sweep & attack sounds modules 2024-01-07 14:30:06 +01:00
kernitus c13385dcd0 Update reflection for 1.20 2024-01-06 19:38:41 +01:00
kernitus 8f85735461 Update placeholderapi support for modesets. #687 2024-01-06 18:36:09 +01:00
kernitus e22877747f Reapply module attack sounds on modeset change 2024-01-06 18:30:49 +01:00
kernitus 104003bc8e Show current modeset on /ocm mode 2024-01-06 18:14:21 +01:00
kernitus 049fd660fc Fix modeset not saving 2024-01-05 19:34:48 +01:00
kernitus 2ff61d8630 Add modeset listener for when player joins or changes world 2024-01-05 17:58:08 +01:00
kernitus a03cc705b6 Allow specifying modesets per-world 2024-01-05 14:01:43 +01:00
kernitus 77a1d4ceec Use POJO for player storage 2024-01-05 11:38:36 +01:00
kernitus 09325911ae Fix gradle output jar name 2023-12-31 16:52:14 +01:00
kernitus 3604c58268 Disable ingametesting 2023-12-31 16:48:26 +01:00
kernitus b466adffdd Don't remove previous last damage 2023-12-29 15:31:19 +01:00
kernitus bc6ce2f5a3 Account for non-entity lastdamage 2023-12-29 15:13:13 +01:00
kernitus 120bf2dca2 Don't send prefix on /ocm 2023-12-29 13:29:19 +01:00
kernitus 1e74bad0c9 Allow customising modeset messages 2023-12-29 13:27:11 +01:00
kernitus b447df664c Add mode command permissions 2023-12-29 12:56:31 +01:00
kernitus c1c3b3514a Defer saving player data 2023-12-29 12:49:59 +01:00
kernitus a529d78a5c Nuke toggle and en/disable commands 2023-12-29 12:39:32 +01:00
kernitus 7484296c50 Re-apply offhand & elytra modules on mode change 2023-12-29 12:34:11 +01:00
kernitus 041cff8f93 Add modeset command 2023-12-28 22:32:18 +01:00
kernitus 302df1b568 Add persistent storage for modesets 2023-12-28 21:33:37 +01:00
kernitus a87bbd200c Move worlds blacklist to module config sections 2023-12-28 13:40:26 +01:00
kernitus bb705851d4 Add per-use combat mode supporting all modules 2023-12-27 22:48:16 +01:00
kernitus f23a8563b3 Remove offhand item if not allowed in world. Fixes #691 2023-12-26 18:34:39 +01:00
kernitus 5123d152ba Fix sword blocking to only when in main hand 2023-12-26 18:20:12 +01:00
kernitus de9e1af80e Add denied message when offhand is disabled 2023-12-26 17:47:02 +01:00
kernitus fc5e880553 Fix sword blocking with offhand allowed. Closes #676 2023-12-26 15:52:10 +01:00
kernitus 96e7d49e85 Allow configuring message prefix. Closes #670 2023-12-25 21:59:20 +01:00
kernitus 0ab59af26b Fix projectile randomness misidentifying straight projectiles & allow configuring projectile types. Closes #678 2023-12-25 20:10:38 +01:00
kernitus ce8b59c762 Add player check to fishing knockback module. 2023-12-25 19:08:51 +01:00
kernitus 5a83b0d8d7 Tweak fishing modules 2023-12-25 18:58:40 +01:00
kernitus 06e5860d1a Add more error checking to update checker. Closes #692 2023-12-25 18:38:12 +01:00
kernitus dedd7089bd Account for environmental damage in overdamage. Fixes #693 2023-12-25 18:26:06 +01:00
kernitus ab69cf415f Specify source of damage with fishing rod attack 2023-12-24 19:30:18 +01:00
kernitus 13b3147ee8 Revert overdamage calculation change 2023-12-24 19:01:57 +01:00
kernitus 455fa74f9b Disable armour EPF randomness during tests 2023-12-22 14:30:21 +01:00
kernitus db2436ff50 Cancel tick task when removing fake player 2023-12-22 13:40:52 +01:00
kernitus bf46ce8008 Avoid hard-coded NMS names in fake player 2023-12-22 13:36:59 +01:00
kernitus bfc2c30750 Clean up fake player spawn code 2023-12-22 12:33:00 +01:00
kernitus ec4dea72bc Fix player disconnect for Paper 2023-12-21 23:51:57 +01:00
kernitus f94b8d7835 Teleport defender instead of using rotate 2023-12-21 22:53:18 +01:00
kernitus 1edfa9dce8 Use Paperweight for remapping 2023-12-21 22:48:39 +01:00
kernitus 25375793c8 Convert to mojang mappings 2023-12-21 16:22:50 +01:00
kernitus 8ad586bb7c Update gitignore 2023-12-21 13:52:43 +01:00
kernitus 99f412bcfd Merge pull request #680 from skbeh/armour-comment-fix
Fix comment about armour damage calculation
2023-12-21 13:48:34 +01:00
skbeh 7ea1e01361 Fix comment about armour damage calculation 2023-08-11 10:27:28 +00:00
kernitus c9de1b4cd1 Bump version to 1.12.3-beta 2023-06-12 13:01:19 +01:00
kernitus 3f6271e168 Remove unused APIs 2023-06-12 12:54:51 +01:00
kernitus dedb55ba4f Build against Spigot 1.20 API 2023-06-09 11:09:32 +01:00
kernitus c66be130bd Bump version to 1.12.2 2023-06-09 11:07:19 +01:00
kernitus 55723c6d58 Make damage listener take into account damage modification from other plugins 2023-06-02 20:18:56 +01:00
kernitus 330c7b6606 Update README.md 2023-06-01 11:03:15 +01:00
kernitus bfd20acf80 Update Spartan anticheat compatibility 2023-06-01 10:56:27 +01:00
kernitus 093c584cdd Clarify shield damage reduction configuration 2023-06-01 09:55:48 +01:00
kernitus d892cca004 Only remove gapple potion effects before adding custom effects. Closes #664 2023-05-31 22:52:09 +01:00
kernitus fbcd6457ce Fix sword blocking when clicking air 2023-05-31 22:04:13 +01:00
kernitus 4b241c93c9 Fix auto update metrics 2023-05-31 17:17:55 +01:00
kernitus 542985f5fb Prevent shift-clicking shield into disabled offhand slot. Closes #666 2023-05-31 17:11:46 +01:00
kernitus 1daaf60111 Update bug_report.md 2023-05-31 13:53:53 +01:00
kernitus 4be7ab2f7a Update readme 2023-05-28 17:39:32 +01:00
kernitus 6002fa5088 Use gradle build 2023-05-28 17:35:53 +01:00
kernitus 0e8dd4adc1 Use internal file repo 2023-05-28 17:20:44 +01:00
kernitus 3f8c3a59e9 Change uses to run 2023-05-28 17:13:51 +01:00
kernitus 23395fd643 Build shadow jar 2023-05-28 17:07:18 +01:00
kernitus b929c59ce1 Fix GitHub CI syntax error 2023-05-28 17:03:55 +01:00
kernitus cf7a0ceddd Rename gradle to gradle.yml 2023-05-28 17:01:38 +01:00
kernitus c3af608d8a Delete maven.yml 2023-05-28 17:00:37 +01:00
kernitus d69cf819f2 Create gradle github CI 2023-05-28 17:00:22 +01:00
kernitus 681bb79acf Add Gradle wrapper 2023-05-28 16:56:32 +01:00
kernitus 49a34de440 Consistent output jar filename 2023-05-28 16:54:55 +01:00
kernitus 1a27589565 Switch from Maven to Gradle 2023-05-28 16:49:51 +01:00
kernitus 86ca899504 Add pie chart for auto updater 2023-05-28 14:16:38 +01:00
kernitus b0e7ca1a70 Bump version to 1.12.1 2023-05-28 12:43:42 +01:00
kernitus 86fabe11e1 Clear all potion effects when eating gapple 2023-05-25 21:55:27 +01:00
kernitus 6c15ed866e Free memory for attack cooldown tracker on player leave 2023-05-24 11:17:32 +01:00
kernitus 9c15014a9a Remove previous potion effects after usage 2023-05-24 11:03:37 +01:00
kernitus f1a145126a Add warning message if previous potion effects not found 2023-05-24 10:56:25 +01:00
kernitus c0d847658e Avoid inventory modification in gapple module 2023-05-24 10:54:35 +01:00
kernitus 9e3dfe101a Avoid cancelling consume event for golden apples 2023-05-23 23:10:46 +01:00
kernitus 112cf2d038 Remove list of interactive blocks. Closes #623 2023-05-23 17:47:13 +01:00
kernitus 03f6c26aae Add auto update downloader 2023-05-23 16:02:34 +01:00
kernitus 8e3d9524eb Cache absorption method per subclass 2023-05-23 12:11:45 +01:00
kernitus b81ee55cd3 Take into account attack cooldown being null 2023-05-23 11:59:27 +01:00
kernitus 5865bd7948 Keep track of player attack cooldown in Spigot <1.16 2023-05-23 11:56:39 +01:00
kernitus 10520d87d6 Update version to 1.12.1BETA 2023-05-23 11:15:19 +01:00
kernitus 89c853241e Revert "Don't use attack cooldown value if below 1.16"
This reverts commit 39f01f3540.
2023-05-23 11:14:18 +01:00
kernitus 39f01f3540 Don't use attack cooldown value if below 1.16 2023-05-19 16:08:50 +01:00
kernitus 1b654cc4e8 Merge pull request #661 from SrBedrock/playerfishevent
Don't listen to cancelled PlayerFishEvent and ProjectileHitEvent
2023-05-19 11:35:59 +01:00
ThiagoROX 418d4cf04b don't listen to cancelled PlayerFishEvent 2023-05-07 04:09:32 -03:00
kernitus 5c45165dcd Module -> OCMModule to avoid ambiguity 2023-04-18 17:23:09 +01:00
kernitus 925daec1a9 Bump version to 1.12.0 for release 2023-04-18 17:07:11 +01:00
kernitus 033723712b Fix error when OCM subcommand not recognised 2023-04-18 17:04:05 +01:00
kernitus 64ce843f82 Add config.yml auto-upgrader 2023-04-18 15:55:49 +01:00
kernitus 8dc49347b7 Amend update checker 2023-04-18 12:42:30 +01:00
kernitus 20d63efdce Updated Maven dependencies 2023-04-18 11:44:59 +01:00
rayzr522 ee93517877 improve explanation & examples for 'worlds' options in config 2023-03-26 18:22:30 -04:00
kernitus a9014b4592 Merge pull request #645 from I-Al-Istannen/fix/use-default-nametag-visibility
Only set nametag visibility if it is valid
2023-02-23 00:31:16 +01:00
I-Al-Istannen a8cd61fee5 Only set nametag visibility if it is valid 2023-02-23 00:06:48 +01:00
kernitus 5f1a8d73b0 Only add Absorption modifier if applicable. Fixes #639 2023-02-16 13:11:53 +00:00
kernitus f7de27d1e5 Merge pull request #638 from I-Al-Istannen/fix/sound-effect-1_19_3
Fix sound effect field extraction on 1.19.3
2023-02-16 13:53:52 +01:00
I-Al-Istannen a271817375 Fix sound effect field extraction on 1.19.3 2023-01-22 18:26:02 +01:00
kernitus 3880ef041a Fix successful overdamage not updating last damage value 2023-01-14 22:44:50 +00:00
kernitus cd362f5df6 Cache absorption & cooldown methods 2023-01-10 23:40:48 +01:00
kernitus f10fe7e414 Fix reflected compatibility methods 2023-01-10 23:30:06 +01:00
kernitus 3fbd4d583e getAbsorptionAmount compat with <1.15 & use Spigot reflected method names 2023-01-06 22:10:34 +01:00
kernitus c257cc280d getAttackCooldown compatibility with <1.16 2023-01-06 21:54:56 +01:00
kernitus ae2b408dc6 Convert codebase to use FunctionChooser 2023-01-06 21:27:11 +01:00
kernitus 43c09b227b Use FunctionChooser for fishing module 2023-01-06 13:30:18 +01:00
kernitus a3e5b841b3 Simplify API function chooser 2023-01-06 13:09:05 +01:00
kernitus 50545ff70a Update feature_request.md 2023-01-06 12:35:55 +01:00
kernitus 5aab53d96c Update bug_report.md 2023-01-06 12:34:56 +01:00
kernitus 7f6e1aa568 Merge remote-tracking branch 'origin/master' 2023-01-06 11:17:48 +01:00
kernitus 7926abe57a Fix is in water for versions between 1.16 & 1.16.3. Closes #622 2023-01-06 11:17:27 +01:00
kernitus eb5e8bb087 Update github action 2022-12-23 13:26:50 +01:00
kernitus 5a1d9d976d Update Maven dependencies 2022-12-23 13:23:06 +01:00
kernitus d667bcdbf1 Disable NMS maven dependency 2022-12-23 13:20:23 +01:00
kernitus e18db6d87e Add SpartanAPI to local repo 2022-12-23 13:17:32 +01:00
kernitus 224592363a Disable in-game tester 2022-12-23 13:00:15 +01:00
kernitus 007806f56c Cleanup & reformat 2022-12-23 12:29:39 +01:00
kernitus 7c81b677a0 Fix 1.9 crit detection & custom weapon compatibility 2022-12-23 11:52:02 +01:00
kernitus 6014c3dc2b Test & improve shield blocking 2022-12-21 13:50:12 +01:00
kernitus 7b5c58740a Update armour-bypassing damage causes to 1.19 2022-12-20 18:06:39 +01:00
kernitus 43d9a31758 Recalculate Resistance and Absorption together with armour 2022-12-20 17:08:17 +01:00
kernitus 8e768c1d74 Test armour enchantments 2022-12-19 22:23:19 +01:00
kernitus e3f2c4740a Fix weakness effect calculations 2022-12-19 18:44:39 +01:00
kernitus 1f3f3e9c9b Clean up old potion effects module 2022-12-19 10:29:31 +01:00
kernitus d5698ff607 Support lingering & dispensed potions 2022-12-18 22:09:23 +01:00
kernitus e9c5efff83 Simplify weakness potion detection 2022-12-18 21:47:05 +01:00
kernitus 3965aacc14 Fix weakness not having event fire & simplify old potions module 2022-08-22 00:02:32 +02:00
kernitus 8dce9742a7 Scale weapon enchants by cooldown 2022-08-21 15:13:17 +02:00
kernitus d07db195ac Fix crits calculations 2022-08-21 13:59:42 +02:00
kernitus bbe3b9a25d Scale damage by attack delay in EDBEL 2022-08-21 12:37:09 +02:00
kernitus e4eb6a3b06 Add old sharpness option to old tool damage 2022-08-20 18:22:29 +02:00
kernitus 780119189c Test all armour types 2022-08-20 13:10:32 +02:00
kernitus 489cb835a0 Fix overdamage calculations interactions with other modules 2022-08-19 17:28:44 +02:00
kernitus 02fbd0646d Exclude ingametesting branch from CI 2022-08-14 23:38:43 +02:00
kernitus cbe2f18112 Exclude ingametesting branch from CI 2022-08-14 23:37:44 +02:00
kernitus e68700f919 Fix overdamage test & lastdamage calculation 2022-08-14 23:31:18 +02:00
kernitus fa046f1ac6 Fix minimum attack speed to 40 2022-08-14 15:14:58 +02:00
kernitus fca1ca5eec Add player bots for testing 2022-08-13 15:37:29 +02:00
kernitus 31b73104bf Add metrics pie chart for each module 2022-08-09 15:49:15 +02:00
kernitus 49424bf8b3 Fix #617 WorldInfo not found on enable command 2022-08-08 16:26:32 +02:00
kernitus 595ef3bb62 Fix offhand swap w/ elytra disabled. Closes #618 2022-08-08 15:58:16 +02:00
kernitus 54422831bb Update Maven dependencies 2022-08-05 12:43:57 +02:00
kernitus ae4a9927c6 Merge pull request #612 from JT122406/master
Update Bstats to Latest API version
2022-08-05 12:22:22 +02:00
kernitus 445e8ddc95 Update issue templates 2022-07-31 15:39:24 +02:00
kernitus f499884219 Update issue templates 2022-07-31 15:36:32 +02:00
J.T. McQuigg b1bc030794 Update Bstats to Latest API version 2022-06-09 23:09:13 -04:00
kernitus 30887bc99b Update command & permissions documentation 2022-05-12 16:03:49 +01:00
kernitus 335abd0ffd Update README.md 2022-05-12 15:45:13 +01:00
kernitus 5c74fe3413 Update to version 1.11 for release 2022-05-12 15:14:56 +01:00
kernitus d10a704609 Generalise tester attacks 2022-05-07 17:39:43 +01:00
kernitus 6030f4ccad Fix tester tally counting twice 2022-05-07 16:57:59 +01:00
kernitus eb59be00cb Don't force potion effect type if not necessary 2022-05-07 15:50:01 +01:00
kernitus e75a6db729 Use JDK 8 version of SpartanAPI 2022-05-07 14:59:28 +01:00
kernitus ab628c6636 Use JDK 15 for GitHub builds 2022-05-01 18:58:24 +01:00
kernitus edd7ed153c Update SpartanAPI dependency 2022-05-01 18:50:31 +01:00
kernitus 6a0c272256 Remove redundant author declarations 2022-04-24 23:35:54 +02:00
kernitus a88f9d671a Change licence to Mozilla Public Licence 2.0 2022-04-24 23:32:27 +02:00
kernitus 1661ad7e50 Disable invulnerability overdamage for melee attack tests 2022-04-24 22:43:30 +02:00
kernitus 1b0d81ccf8 Add world-wide cooldown toggle command. Closes #481 2022-04-24 22:35:10 +02:00
kernitus 0a0909bd56 Fix old brewing stands in >1.17. Fixes #571 2022-04-24 18:34:14 +02:00
kernitus 6fda20ad68 Don't use rotateAroundY in <1.14. Fixes #606 2022-04-24 18:07:29 +02:00
kernitus 9dcc9e2034 Allow gapple cooldown if old effects disabled 2022-04-24 17:19:04 +02:00
kernitus e808a8db14 Add permission for sword blocking. Closed #553 2022-04-24 15:59:50 +02:00
kernitus 1652ab8bca Add OCM command tab completion 2022-04-24 15:50:40 +02:00
kernitus 4e43c0002a Toggle cooldown via console & add configurable messages. Closes #539 & #493 2022-04-24 11:59:42 +02:00
kernitus 7f39274b81 Allow non-nms packets through 2022-04-24 10:36:01 +02:00
kernitus 1df299b3d6 Don't override enderpearl fall damage 2022-04-23 20:26:24 +02:00
kernitus 10adca1127 Restore inventory && tally test results 2022-04-23 19:55:01 +02:00
kernitus 51398c1c33 Use WeakHashMaps where possible 2022-04-23 18:36:19 +02:00
kernitus caf99eb59e Avoid random knockback 2022-04-23 18:23:51 +02:00
kernitus 9e127e671c Merge pull request #602 from I-Al-Istannen/fix-1.18-packets
Add 1.18.x method names
2022-04-23 17:55:13 +02:00
kernitus 89fbf3861f Merge branch 'master' into fix-1.18-packets 2022-04-23 17:54:49 +02:00
kernitus ad6d51aa50 Merge pull request #608 from kernitus/unit-testing
In-game testing
2022-04-23 17:47:55 +02:00
kernitus 9bdf72fef2 Remove unit test dependencies 2022-04-23 17:44:17 +02:00
kernitus ecfcdee26d Remove unit tests 2022-04-23 17:39:37 +02:00
kernitus 528436c366 Automated testing for overdamage 2022-04-11 00:52:18 +01:00
kernitus bda5a03418 Set last damage to base + strength + crits 2022-04-10 22:11:39 +01:00
kernitus 51692d3d77 Merge remote-tracking branch 'origin/unit-testing' into unit-testing
# Conflicts:
#	src/test/java/kernitus/plugin/OldCombatMechanics/PlayerAttackTest.java
2022-04-09 20:12:23 +01:00
kernitus d704fced4d Add in-game tester & fix overdamage order 2022-04-09 20:11:19 +01:00
I-Al-IstannenandHannes Greule a63503a8f7 Add 1.18.x method names
Spigot decided to just outright drop the method mappings, breaking
everything relying on them being stable. The replacement is some ugly
monkey-patching :^)

Co-authored-by: Hannes Greule <SirYwell@users.noreply.github.com>
2022-03-18 21:11:33 +01:00
kernitus ee26c07d89 Make GitHub actions update snapshots 2021-12-21 15:30:13 +01:00
kernitus dbeae5b7e4 Make oldarmourstrength module work with tests 2021-12-21 14:03:57 +01:00
kernitus 03c5d2a53b Update bug_report.md 2021-11-14 15:49:14 +00:00
kernitus 1bde80648a Use Java 15 to compile tests 2021-09-04 14:15:17 +01:00
kernitus 2cf0b78364 Trigger github actions for all branches 2021-09-04 14:07:55 +01:00
kernitus 36c64d2bf2 Move attack event to mock player class 2021-09-04 14:02:02 +01:00
kernitus 5aab9d31ca Add unit tests 2021-09-04 13:13:41 +01:00
kernitus d222286fd8 Merge pull request #559 from I-Al-Istannen/fix/whitelist-custom-packets
Whitelist mcnative chat packets as they intentionally send them
2021-09-04 10:13:32 +01:00
I-Al-Istannen 4f7570755a Whitelist mcnative packets as they intentionally send them
Sending something that is not an NMS packet normally is a bug, but the
whitelisted plugins does it intentionally. We should not interfere with
it, but any issues that might arise are out of scope for this project.
2021-08-29 20:09:53 +02:00
kernitus 1c88fc9a7e Update maven.yml 2021-08-29 13:19:13 +01:00
kernitus 999e134891 Add GitHub workflow 2021-08-29 13:05:15 +01:00
kernitus c28e9557b4 Set correct last damage on invulnerability 2021-08-29 12:59:50 +01:00
kernitus 6495396dd3 Ignore freeze damage if version < 1.17. Closes #547 2021-07-27 18:37:57 +01:00
kernitus 0bf90ff61a Update README.md 2021-07-27 18:17:40 +01:00
kernitus d0a3755831 Up version number to 1.10.0 2021-07-27 17:59:51 +01:00
kernitus c5712dd226 Add Zombie Horse & Zombie Villager smite support 2021-07-27 17:44:19 +01:00
kernitus cf60e4eee8 Add Freeze damage support 2021-07-27 17:39:06 +01:00
kernitus fe6f58eb32 Support old & new tool names 2021-07-27 17:24:07 +01:00
I-Al-Istannen cefbd0db2c Update reflection usage to work in 1.17 (#536)
This patch uses the qualified 1.17 names of classes for lookup and
strips them out in earlier versions.

Additionally some fields are now matched by the simple name of their
type instead of the field name.
2021-07-27 16:48:04 +01:00
kernitus 12cb6c3809 Update tool list 2021-07-27 16:42:37 +01:00
Rayzr522 e9ba161b89 Reload all configs, even of disabled modules 2021-06-17 21:54:16 -04:00
Rayzr522 5bb042b09f Make ModulePlayerCollisions not die on 1.17 2021-06-17 21:53:58 -04:00
Rayzr522 d0018dd581 Handle failed initialization in packet utils 2021-06-17 21:37:09 -04:00
Rayzr522 5424472318 Dont use Messenger before its initialized 2021-06-17 21:03:31 -04:00
Rayzr522 e17e43570d Dont explode if SweepPacketDetector fails to init 2021-06-17 20:56:42 -04:00
kernitus f5d4821be6 Update Spigot dependency & version number 2021-06-17 14:02:22 +01:00
kernitus 82b5f32d46 Clean up module player knockback code 2021-06-16 15:34:44 +01:00
M0diiandkernitus 6fd52189db Gapple cooldown messages (#526)
* Add configurable gapple cooldown messages

Co-authored-by: kernitus <2789734+kernitus@users.noreply.github.com>
2021-06-16 15:26:58 +01:00
kernitus f568baaf03 Don't use isInWater < 1.16. Closes #533 2021-06-16 12:55:20 +01:00
kernitus a50cb79bda Merge pull request #532 from MWHunter/master
Ignore cancelled knockback events
2021-06-16 12:37:15 +01:00
kernitus c2aad88d71 Account for invulnerability overdamage in old tool module. Closes #458 2021-06-15 16:47:23 +01:00
DefineOutside ec0bd8217b Try to fix random knockback 2021-06-14 21:16:31 -05:00
kernitus 2ffb01b531 Ignore thorns attacks in old tool damage. Closes #455 2021-06-13 15:19:02 +01:00
kernitus b177662e38 Don't override tool damage if not default 1.9 value 2021-06-09 22:11:27 +01:00
kernitus dcd9ed1e52 Fix shield-blocking damage reduction & armour durability 2021-06-09 15:09:42 +01:00
kernitus 9753fe7731 Add OldCriticalHits module. Closes #410 and #496 2021-06-03 19:57:12 +01:00
DefineOutsideandkernitus a88b6d22cd Old Player knockback (#483)
* Reverts knockback formula to 1.8
 * Disables netherite knockback resistance

Co-authored-by: kernitus <2789734+kernitus@users.noreply.github.com>
2021-05-01 17:56:42 +01:00
kernitus 84d06627e2 Add 1.9.0 features to feature list 2021-01-04 13:05:08 +01:00
kernitus fcebce2726 Up version to 1.9.0 2021-01-04 12:46:56 +01:00
kernitus a4cb7f7bd3 Disable ModuleChorusFruit by default 2020-12-26 16:06:05 +01:00
kernitus 59fc09cb11 Respect worlds option for ModuleCHorusFruit 2020-12-26 16:05:33 +01:00
kernitus cedac9883c Add worlds option to OldBurnDelay 2020-12-26 13:46:06 +01:00
kernitus 45b1f4aab7 Add worlds option to OldPotionEffects 2020-12-26 13:38:23 +01:00
kernitus 2f4c389f54 Up version to 1.9.0BETA 2020-12-26 13:20:36 +01:00
kernitus a643af60b9 Add actual guidance to CONTRIBUTING.md 2020-12-26 13:18:41 +01:00
DefineOutsideandkernitus 99c418c80e Added module for reverting fishing rod velocity back to 1.8 (#466)
* Added module for reverting fishing rod velocity back to 1.8

* Module now exactly replicates 1.8 rods in all versions from 1.9 to 1.16

* Moved if statement outside of loop for optimisation and fixed an incorrect comment

* Clean up FishingRodKnockback & use internal utilities

* Remove unused variable & fix gravity comment

* Make Fishing task only run when necessary

Co-authored-by: kernitus <2789734+kernitus@users.noreply.github.com>
2020-12-26 12:30:45 +01:00
kernitus 4cf9bfd720 Add ModuleAttackFrequency #19 2020-12-24 18:10:11 +01:00
kernitus 6f0c7907ad Clean up OCM command handling 2020-12-24 15:08:05 +01:00
kernitus 0f32587abd Fix error when running /ocm 2020-12-24 14:57:30 +01:00
kernitus 0bfb6614b7 Add world blacklist option, closes #480 2020-12-22 14:19:04 +01:00
kernitus e4e99b419c Make DisableEnderpearlCooldown ignore launch events if already cancelled 2020-12-22 14:18:29 +01:00
kernitus dbfbdb1967 Fix DisableEnderpearlCooldown not checking if enabled in current world 2020-12-22 14:17:09 +01:00
kernitus c7a0d1e402 Fix disabling enderpearl cooldown message not updating with reload 2020-12-22 13:53:06 +01:00
kernitus 775dda92f5 Add message for disabled crafting. Closes #471 2020-12-22 13:52:10 +01:00
kernitus 90074e766a Give ModulePlayerRegen more leeway with timing inaccuracies for #474 2020-12-22 13:36:34 +01:00
kernitus cd48e2972d Make Module name & config path final 2020-12-22 13:19:18 +01:00
kernitus 7f1450c73f Add message for enderpearl cooldown #468 2020-12-22 13:17:32 +01:00
kernitus 3d01f0f192 Update README.md
Change bStats image link
2020-12-21 23:01:58 +01:00
kernitus 35ed292526 Fix OldPlayerRegen not accounting for exhaustion changes when cancelling event & not healing, and not healing on first foodTickTimer expiry 2020-12-21 22:38:51 +01:00
kernitus 1ab60a0f2c Update Maven dependencies 2020-12-21 15:19:16 +01:00
kernitus 92abec803d Merge pull request #459 from I-Al-Istannen/master
Rework player collision packet manipulation
2020-10-03 15:59:54 +01:00
I-Al-Istannen 3025f7416a Rework player collision packet manipulation
There were quite a few bug reports about players being kicked as a team
was created again.
This was caused by the updateToPacket method adjusting the ID of the new
packet to the ID of an older one, leading to duplicated packets.
Packet queueing and sending can have a large delay and lead to packets
appearing out of order or after they were already replaced.

This patch changes the mechanism:
- Packets are not changed but new ones are created
- The *last* packet is kept
- OCM now does not generate random names but instead counts upwards
- OCM now tries to correctly disband its own teams
- A few cases in which OCM created more teams than needed were fixed
2020-09-07 13:36:37 +02:00
I-Al-Istannen aea1130325 Synchronise access to player team map (#456) 2020-08-27 17:48:13 +01:00
I-Al-Istannen faebd51d54 Individual golden apple cooldown (#446) 2020-08-09 17:55:14 +01:00
Rayzr522 0efd9dc8af Switch PlaceholderHook to PlaceholderExpansion 2020-08-05 14:36:15 -04:00
kernitus 6a002c1e20 Merge pull request #430 from I-Al-Istannen/master
Cache SweepPacketDetector
2020-07-21 19:38:02 +01:00
I-Al-Istannen c444fbd692 Cache SweepPacketDetector 2020-07-21 19:19:04 +02:00
kernitus 5053eb8419 Merge pull request #426 from I-Al-Istannen/master
Prevent duplicate packet listener registrations
2020-07-19 17:47:26 +01:00
I-Al-Istannen e30182e803 Prevent duplicate packet listener registrations
Previously the attack sound module would attach two listeners after a
reload. This doesn't really hurt as they will come to the same
conclusion, but it is just wasted computation time.
2020-07-19 11:08:49 +02:00
Rayzr522 5692df08e2 Ignore attributes that are null 2020-07-18 23:39:44 -04:00
kernitus e4dcad5361 Update README.md 2020-07-12 12:16:26 +01:00
kernitus 6b50113062 Upped version number for 1.8.1 release 2020-07-10 19:48:14 +01:00
kernitus 2e13adaaae Added crafting table to list of interactive blocks 2020-07-10 19:45:55 +01:00
kernitus c0ccc4eaad Cancel inventory hand swap in disable-offhand module 2020-07-10 18:50:25 +01:00
kernitus b5ba9479ab Skip adding mobs not supported in running MC version, #421 2020-07-09 19:20:58 +01:00
I-Al-Istannen cdc5c89850 Add a module to disable new attack sounds (#415)
* Add a module to disable the new attack sounds
2020-07-01 13:09:51 +02:00
kernitus 671e0cfd34 Updated Spigot dep. & list of undead & arthropods 2020-06-29 14:58:19 +02:00
kernitus 6b98257511 Ignore Elytras in old armour durability module 2020-06-29 14:14:19 +02:00
kernitus 4e05ad6b6a Fixed #402, no per-world shield damage reduction 2020-06-29 13:18:57 +02:00
kernitus dadb19d75a Fixed gapples being used up while in Creative 2020-06-29 13:15:54 +02:00
kernitus b73d4e4997 Fixed #411, no Advancements on pre-1.12 2020-06-29 13:02:25 +02:00
kernitus e97b50a064 Added support for Netherite weapons & fixed shovels 2020-06-29 12:28:20 +02:00
kernitus 3857cb0938 Update bug_report.md 2020-05-28 20:42:59 +01:00
kernitus 602cfb01c7 Account for unbreaking in OldArmourDurability module 2020-05-21 20:35:54 +01:00
kernitus 2abab21008 Update README.md 2020-05-19 22:10:42 +01:00
kernitus 45ff0dd185 Update README.md 2020-05-19 22:09:25 +01:00
kernitus 46bac42a90 Changed version to 1.8.0 for release 2020-05-19 21:46:55 +01:00
kernitus 05fef9c84a Fixed typo in plugin.yml 2020-05-19 21:46:38 +01:00
kernitus 8209ae80ae Fixed sword blocking no longer working when clicking air 2020-05-19 21:44:44 +01:00
Rayzr522 e8408ec819 Simplify state management even further 2020-05-19 15:51:55 -04:00
Alessandro 4c21d69237 Fixes Memoryleak (#389)
* Prevent RunnableSeries to stay in memory when putting a new instance into correspondingTasks

* CheckBlocking task automatically stops if the player is offline
CheckBlocking task is cancelled inside without calling any reference from the map
Put method is called uniquely after clearing and stopping tasks
2020-05-19 15:32:02 -04:00
kernitus 4b86ee9b7a Renamed Old Burn Delay module 2020-05-19 19:58:15 +01:00
d3df10995f Bring back old fire behavior (#385)
* Added OldFireDamage module

Co-authored-by: ceandream <cediderstylezzcedi@gmail.com>
Co-authored-by: kernitus <2789734+kernitus@users.noreply.github.com>
2020-05-19 19:46:06 +01:00
kernitus aaea7c7391 Added OldArmourDurability module. Closes #387 2020-05-19 15:40:06 +01:00
kernitus 12225f5427 Reduced default damage done by fishing rod knockback 2020-05-16 19:30:51 +01:00
kernitus 5fb06c743a Update CONTRIBUTING.md 2020-05-16 19:29:02 +01:00
kernitus d944b2a157 Update feature_request.md 2020-05-16 19:26:20 +01:00
kernitus f2c4a16e2d Added option for fishing rod knockback on mobs. Closes #386 2020-05-16 19:06:54 +01:00
kernitus adeabf1032 Updated version number to 1.8.0BETA 2020-05-16 18:40:39 +01:00
kernitus e6f964d449 Made cancelDraggingIn configurable in ModuleFishingKnockback. Closes #279 2020-05-16 18:39:40 +01:00
kernitus f1691b1351 Disable enderpearl launch time tracking if cooldown disabled 2020-05-16 17:56:14 +01:00
kernitus c9a13443d5 Added custom gapple cooldown. Closes #122 2020-05-16 17:50:36 +01:00
kernitus cf7fe6975f Added configurable enderpearl cooldown. Closes #169 2020-05-16 17:13:46 +01:00
kernitus 7eb2747426 Fixed #331, disable projectile randomness for multishot 2020-05-15 16:02:33 +01:00
kernitus c6d0b67033 Added Barrel to interactive list, lowered projectile knockback damage 2020-05-15 12:52:54 +01:00
kernitus 10a790a3cf Fixed Projectile Knockback with Absorption 2020-05-15 12:34:40 +01:00
kernitus 447a8dc984 Disabled spammy debug message 2020-05-15 11:38:27 +01:00
kernitus 5c7d70a8b2 Remove MCStats Metrics 2020-05-15 10:54:02 +01:00
kernitus b4329534b2 Updated Maven dependencies 2020-05-15 10:52:14 +01:00
kernitus b79e216913 Updated bStats to 1.7 2020-05-15 10:38:27 +01:00
kernitus c542525284 Added small OCM icon 2020-05-12 18:36:06 +01:00
kernitus 59c0910ec6 Update README.md 2020-05-12 16:50:00 +01:00
kernitus 6a1e15ae1d Added images 2020-05-12 16:45:13 +01:00
kernitus 14d47b707a Added missing event import 2020-05-12 16:29:08 +01:00
kernitus 825c089635 Merge pull request #380 from detobel36/patch-3
Remove player UUID from healing HashMap on quit
2020-05-12 15:50:34 +01:00
Rémy Detobel 8b2ff5f631 Remove player from healTimes map on quit 2020-05-03 10:49:45 +02:00
Peter Blood 05382e15a9 Merge pull request #375 from FearGames/master
Fix memory leak, update bstats version
2020-04-23 19:18:23 -04:00
Gabriele C 84e4fb67fc Remove useless code 2020-04-20 21:19:57 +02:00
Gabriele C 59ecdc25d9 Fix memory leak, update bstats version 2020-04-20 17:37:29 +02:00
kernitus d27efbdb53 Merge pull request #359 from I-Al-Istannen/master
Silently swallow non-packet packets
2019-12-29 15:00:48 +01:00
I-Al-Istannen 4df12fd702 Silently swallow non-packet packets
If the packet listener receives a non-NMS packet, it now just
silently moves on with life and ignores it. Previously it
threw a fit, crashing and disconnecting the player.

The problem is still logged in debug mode, to ease debugging.

Closes: #355
2019-12-28 18:50:26 +01:00
kernitus e1e586ad78 Delete old issue template 2019-11-26 11:48:08 +00:00
kernitus 7df798ff47 Update issue templates
Bug report and Feature request templates
2019-11-26 11:47:13 +00:00
kernitus d6719cf5b2 Merge pull request #349 from kernitus/dependabot/maven/io.netty-netty-all-4.1.42.Final
Bump netty-all from 4.1.21.Final to 4.1.42.Final
2019-10-12 11:50:57 +01:00
dependabot[bot] b19225715b Bump netty-all from 4.1.21.Final to 4.1.42.Final
Bumps [netty-all](https://github.com/netty/netty) from 4.1.21.Final to 4.1.42.Final.
- [Release notes](https://github.com/netty/netty/releases)
- [Commits](https://github.com/netty/netty/compare/netty-4.1.21.Final...netty-4.1.42.Final)

Signed-off-by: dependabot[bot] <support@github.com>
2019-10-12 10:49:00 +00:00
Rayzr522 892b0d793f Return empty collection instead of null for team members in team packets 2019-10-03 18:02:51 -04:00
kernitus e34a26f066 Changed bStats bar chart to advanced pie chart 2019-09-16 21:05:28 +01:00
kernitus ff7be828f5 Update README.md 2019-09-16 19:38:23 +01:00
kernitus b83f094a8d Updated version 2019-09-11 16:11:17 +01:00
kernitus ffe311e60e Update README.md 2019-09-11 12:44:57 +01:00
kernitus 0dacfd5282 Fixed eating gapples not incrementing statistics or advancements 2019-08-29 13:37:05 +01:00
kernitus 2980111ec5 Made plugin respect gapple consume event cancellation 2019-08-29 11:22:32 +01:00
259 changed files with 35131 additions and 8375 deletions

No files matched your search

@@ -0,0 +1,30 @@
---
name: commit-preparation
description: Supports preparing commits, pre-commit hooks, Spotless, formatting or linting, validation before commit, staging files, and writing conventional commits.
---
# Commit Preparation
Use this skill whenever an agent is preparing a repository state for commit, validating changes before commit, handling pre-commit hooks, or choosing staging and commit-message details.
## Required checks before committing
- Inspect `git status`, `git diff`, and recent commits before committing.
- Run `./gradlew spotlessCheck` before committing.
- Run `./gradlew check` before committing, unless a narrower validation is clearly justified for the change and that justification is reported in chat.
- Run relevant integration tests for behaviour, compatibility, module, or bug-fix changes.
- Use `integration-test-verification` to choose an integration-test matrix and to triage integration-test failures.
- Never skip, weaken, disable, or version-gate tests to make validation pass. Fix the root cause or report the failure as a blocker unless the user explicitly approves a coverage reduction.
## Staging and commit messages
- Stage only intended files, and re-check the staged diff before committing.
- Use conventional commits.
- Keep commit messages subject-only unless the user explicitly asks for a body.
- Make the subject release-note-friendly and comprehensible without the type prefix or body.
- For issue, ticket, or bug-report driven work, include the ticket number in the commit subject, such as `fix(modesets): correct stale stored world modesets (#865)`.
## Reporting
- Keep validation findings in chat unless committing is explicitly requested.
- If validation is narrowed, skipped, blocked, or failing, report the reason and any remaining risk before committing.
@@ -0,0 +1,58 @@
---
name: compatibility-strategy
description: Use for Java 8 backports, Bukkit/Paper version differences, NMS/reflection, PacketEvents compatibility, fake-player implementation choices, and feature-detection design; do not use for release publishing, PR wording, or simple test-running decisions.
---
# Compatibility Strategy
Use this skill when a change must behave across legacy and modern Bukkit/Paper versions or across different Java runtimes.
## When to use
- Java 8 source compatibility or dependency updates that might introduce newer APIs.
- NMS, CraftBukkit, Paper, PacketEvents, XSeries, or reflection-sensitive changes.
- Fake-player internals, attack delivery, fire ticks, entity ticking, or synthetic network plumbing.
- Decisions about feature detection versus hard-coded Minecraft version checks.
- Fallback behaviour on Spigot, Paper, legacy 1.12/1.9, or modern 1.20.5+ servers.
## When not to use
- Do not use for release asset or publishing checks; use `release-readiness-review`.
- Do not use for config/modeset-only changes unless compatibility affects the implementation; use `module-config-change` first.
- Do not use for PR prose only; use `pr-draft-summary`.
## Required stance
- Prefer direct supported APIs when present.
- Use reflection only as a fallback or compatibility bridge, especially off hot paths.
- Prefer feature detection by class, method, field, component, or capability over hard-coded version numbers.
- Keep Java 8-targeted code free of Java 9+ collection factories, `Stream.toList()`, records, pattern matching, and other newer language/runtime features.
- For NMS access, prefer `utilities.reflection.Reflector` helpers where they fit the problem.
- Avoid hard-coded versioned NMS class names unless no heuristic or API-based path is viable.
## Compatibility checklist
1. Identify the minimum supported Java runtime for the touched source set.
2. Identify whether Bukkit/Paper exposes a direct API for the target behaviour.
3. If an API may be missing or backported, add runtime feature detection.
4. Choose a safe fallback that preserves existing behaviour where feature detection fails.
5. Keep diagnostics actionable when every candidate path fails.
6. Select validation versions that cover the compatibility boundary, usually a legacy version plus a modern Paper version.
## Java 8 reminders
- Use `collect(Collectors.toList())` instead of `Stream.toList()`.
- Use mutable collections wrapped with `Collections.unmodifiable*` where immutable Java 9 factories would otherwise be tempting.
- Avoid records, sealed classes, switch expressions, pattern matching, and `var` in Java source.
- Main build config targets Java 8 via `options.release.set(8)` and Kotlin `jvmTarget = 1.8`.
## Fake-player and NMS reminders
- The primary fake-player path is modern-NMS-oriented and relies on reflection remapping.
- Legacy 1.12 uses versioned NMS and currently needs dedicated or version-aware handling for realistic fake-player tests.
- Fake players are simulated login/network entities, not real network clients.
- PacketEvents may need fake users or explicit client-version seeding in synthetic scenarios.
## References
- Broader test-harness and historical notes: `../integration-test-verification/references/relocated-agents-notes.md`.
@@ -0,0 +1,52 @@
---
name: dependabot-pr-review
description: Use for Dependabot PRs, dependency bumps, Gradle or Maven dependency updates, GitHub Actions updates, dependency changelog/licence/release-note review, JVM/classfile checks, and validation recommendations.
---
# Dependabot PR Review
Use this skill when reviewing dependency or workflow version updates. Keep the review evidence-based, scoped to the changed dependency, and explicit about what must be checked before merge.
## Cross-load related skills
- Use `integration-test-verification` for integration-test matrix selection, compact failure triage, Kotest registration, or any full integration log hand-off.
- Use `compatibility-strategy` for Java 8, Bukkit/Paper/Spigot, NMS/reflection, PacketEvents, fake-player, or API compatibility concerns.
- Use `release-readiness-review` for licence, shaded dependency, release-note, publishing, or asset-impact questions.
## Review workflow
1. Identify the update manager, dependency scope, touched files, and whether the dependency is production, compile-only, test-only, integration-test-only, or GitHub Actions.
2. Read the PR diff and the upstream release notes, changelog, migration guide, and security notes for every updated version range.
3. Check licence changes and whether the dependency ships in the release jar, shaded jar, or integration-test jar.
4. Check JVM bytecode, classfile version, toolchain, and Gradle module metadata against main Java 8 support and the integration-test Java bands.
5. Check Bukkit/Paper/Spigot compatibility when the dependency is server-facing, API-adjacent, PacketEvents-adjacent, or affects plugin loading.
6. Check shading, relocation, service files, transitive dependency, and duplicate class risks, especially for anything bundled into the plugin jar.
7. For GitHub Actions bumps, check Node/runtime changes, runner image requirements, permission model changes, and deprecated input/output behaviour.
8. Inspect CI status and rerun the narrowest relevant validation where appropriate; prefer static checks for documentation-only or metadata-only bumps.
9. Root and user-facing agents must not open `build/integration-test-logs/*.log` directly. If compact summaries are insufficient, delegate full integration log inspection to a subagent.
## Validation expectations
- Do not skip, weaken, disable, or version-gate tests to make a dependency update pass.
- Match validation to risk: build metadata can often use wrapper/config checks; runtime libraries may need unit or integration tests; server-facing changes may need selected Paper matrix runs.
- Prefer checking the produced dependency graph or shaded jar contents when packaging impact is plausible.
## Verdicts
- `merge`: low-risk update, relevant notes reviewed, CI/validation is green, and no follow-up is needed.
- `merge after checks`: likely safe, but wait for named CI jobs, dependency graph checks, or narrow validation.
- `hold`: compatibility, licence, packaging, JVM, server API, or validation concerns need investigation or code changes.
- `close/ignore`: update is incompatible, not useful for this branch, duplicates another update, or should be deferred by policy.
## Review output template
```markdown
### Dependency review
- Scope: production / compile-only / test-only / integration-test-only / GitHub Actions
- Release notes checked:
- Licence/packaging impact:
- JVM and server compatibility:
- Shading/duplicate class risk:
- Validation:
- Verdict: merge / merge after checks / hold / close/ignore
```
@@ -0,0 +1,61 @@
---
name: github-issue-investigation
description: Use for GitHub issue triage and local investigation, including gh issue workflows, picking a fresh issue, reproducing issue reports, and planning regression coverage.
---
# GitHub Issue Investigation
Use this skill when investigating GitHub issues locally, especially bug reports that may need reproduction and durable regression coverage before any fix is attempted.
## Default posture
- Treat GitHub as read-only by default.
- `gh issue list` and `gh issue view` are allowed for investigation.
- Do not comment, label, assign, close, edit milestones, or otherwise mutate public GitHub state unless the user explicitly asks later.
- Keep findings chat-only by default; do not create plans, state files, or persistent notes unless the user asks.
- Try to reproduce before fixing. Prefer a regression test that can remain in the repository when practical.
- After local findings, let the user decide whether to fix, comment, or abandon the issue.
## When an issue number or URL is provided
Inspect that specific issue with `gh issue view`. Summarise:
- reported behaviour and expected behaviour;
- environment, configuration, server, plugin, or version hints;
- missing information that would affect local reproduction;
- related code paths and existing tests worth checking;
- a local reproduction recommendation, including the narrowest likely test or manual scenario.
Avoid changing GitHub state while doing this. If the issue is clearly a duplicate, support question, feature request, or waiting for reporter information, report that and explain whether local investigation is still worthwhile.
## When no issue is provided
Use `gh issue list` to build a shortlist rather than choosing silently. Present about 3-5 candidate issues with:
- issue number and title;
- why the issue looks actionable;
- likely affected area;
- reproduction feasibility;
- reasons to avoid it, if any.
Do not require or prefer positive labels. Actively avoid issues labelled exactly `awaiting testing` or effectively equivalent, because those are usually awaiting reporter or user verification rather than fresh local investigation.
Prefer issues with observable broken behaviour and enough detail to reproduce. Avoid issues that are obvious duplicates, support questions, feature requests, waiting on reporter information, already assigned or visibly in progress, dependent on unavailable external services, private servers, client-only behaviour, or anything requiring GitHub-side mutation before local investigation can proceed.
## Reproduction and coverage
- Reproduce before implementing a fix where practical.
- Prefer integration tests when the behaviour depends on Bukkit/Paper server behaviour, plugin lifecycle, events, fake players, PacketEvents, or cross-version compatibility.
- Use or trigger `integration-test-verification` for integration-test matrix selection, runs, Kotest registration, compact result triage, and failure investigation.
- Respect the repository rule that root and user-facing agents must not read full `build/integration-test-logs/*.log` files directly; use compact summaries first and delegate log inspection only when needed.
- If integration coverage is not feasible, explain why and propose the next best durable coverage, such as a focused unit-level test, synthetic event test, or documented manual reproduction.
## Routing after investigation
Keep this skill investigation-focused. If the user later requests a fix:
- use `TDD` for repro-test-first work;
- use `Planner` when the fix needs planned multi-step work or broader design decisions;
- use `BuildReview` for small standalone quick changes.
Do not start a fix, comment on GitHub, or create persistent planning artefacts merely because an issue looks actionable.
@@ -0,0 +1,75 @@
---
name: integration-test-verification
description: Use when running, selecting, authoring, or triaging integration tests, Kotest specs, Gradle matrix tasks, FakePlayer-backed test cases, or compact test-result files; do not use for unrelated production-only edits, release publishing, or general PR prose.
---
# Integration Test Verification
Use this skill for the repository's Paper-backed integration-test harness and for deciding how to validate a change without opening large server logs by default.
## When to use
- Selecting `./gradlew integrationTest` versus a version-limited matrix run.
- Adding or changing Kotlin integration tests under `src/integrationTest/kotlin`.
- Registering a new spec in `KotestRunner`.
- Triaging `checkTestResults<version>` output or `plugins/OldCombatMechanicsTest/test-failures.txt`.
- Reasoning about tests that use `FakePlayer`, synthetic Bukkit events, PacketEvents wrappers, or direct module handler calls.
## When not to use
- Do not use for release asset, Hangar, CurseForge, or licence checks; use `release-readiness-review`.
- Do not use for module assignment or config migration design unless the task is specifically about test coverage; use `module-config-change`.
- Do not use for broad Java 8/NMS design unless the immediate question is test-harness behaviour; use `compatibility-strategy`.
## Non-negotiable rules
- Root and user-facing agents must not read `build/integration-test-logs/*.log` directly; log inspection is allowed only by subagents, and only when needed for integration-test triage or when explicitly requested.
- Prefer compact console output and `plugins/OldCombatMechanicsTest/test-failures.txt` first for failure triage.
- Do not disable, skip, weaken, or version-gate tests as a way to make validation pass. Fix the root cause, or report the failure as a blocker unless the user explicitly approves a coverage reduction.
- New integration specs must be added to `KotestRunner.withClasses(...)`; autoscan is disabled.
- Keep paths relative to the repository root, plugin data folder, or server run directory.
- Avoid starting servers for documentation-only or opencode-config-only changes.
## Harness quick reference
- Full matrix: `./gradlew integrationTest`.
- Selected matrix: `./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11,1.12`.
- Wrapper helper: `scripts/run-integration-matrix.sh -v 1.19.2,1.21.11 [-s '*SpecName*'] [-t '*test name*']` keeps Gradle and Kotest filter arguments consistent.
- Compact result summary: `scripts/summarise-integration-results.sh` reads only `run/*/plugins/OldCombatMechanicsTest/test-results.txt` and `test-failures.txt`; it does not open full server logs.
- Matrix suggestion helper: `scripts/suggest-integration-matrix.sh [changed-path ...]` gives a conservative starting point for versions and filters.
- Individual task names follow `integrationTest<version>` with punctuation mapped to underscores, for example `integrationTest1_19_2`.
- Test plugin entrypoint: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.
- Success handoff: `plugins/OldCombatMechanicsTest/test-results.txt` contains `PASS`.
- Failure summary: `plugins/OldCombatMechanicsTest/test-failures.txt` contains a compact list, capped for CI readability.
- Java 11+ server targets use Kotest 6 (`KotestRunner`, `KotestProjectConfig`).
- Java 8 targets use `LegacyTestRunner`, currently only a smoke path.
## Validation selection template
1. Identify the changed feature and affected server bands.
2. Prefer the narrowest version set that covers the compatibility boundary.
3. If the change touches packet handling, include a modern PacketEvents-covered version.
4. If the change touches legacy behaviour or Java 8 APIs, include `1.12` where practical.
5. If the change is documentation or opencode-only, run filesystem/schema checks rather than server tests.
Example recommendation:
```text
Run: ./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11
Reason: covers modern Paper plus the PacketEvents path affected by the change.
Avoid: opening build/integration-test-logs from a root or user-facing agent; use a subagent only when compact failure output is insufficient or log inspection is explicitly requested.
```
## Known shortcut patterns
Some tests intentionally exercise module code without fully realistic gameplay plumbing:
- `GoldenAppleIntegrationTest`, `OldPotionEffectsIntegrationTest`, `OldArmourDurabilityIntegrationTest`, `PlayerKnockbackIntegrationTest`, `SwordBlockingIntegrationTest`, and `SwordSweepIntegrationTest` use synthetic events in places.
- `PlayerKnockbackIntegrationTest` and `SwordSweepIntegrationTest` directly invoke module handlers.
- Packet cancellation tests construct and dispatch PacketEvents events directly for deterministic coverage.
Treat these shortcuts as design choices unless the task asks for end-to-end realism.
## References
- Historical integration and regression notes: `references/relocated-agents-notes.md`.
@@ -0,0 +1,98 @@
# Relocated AGENTS.md Historical Notes
This reference preserves detailed repository context that previously made the root `AGENTS.md` difficult to scan. Keep always-on routing rules in `AGENTS.md`; keep operational detail here or in a more specific skill.
## Integration test harness
- Integration tests live in `src/integrationTest/kotlin` and are packaged into `OldCombatMechanics-<version>-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/<version>.log`; `checkTestResults<version>` prints only a compact summary/failures to the console.
- `KotestRunner` writes `plugins/OldCombatMechanicsTest/test-failures.txt` so CI can surface failure reasons without opening the full server log.
- PacketEvents is shaded into the plugin; integration tests do not inject external packet libraries.
- `relocateIntegrationTestClasses` relocates PacketEvents references in test classes only.
- `integrationTestJar` embeds relocated test classes plus runtime dependencies, excluding PacketEvents so the test plugin resolves PacketEvents from the main plugin’s shaded copy.
- `PacketCancellationIntegrationTest` uses a cancellable `CompletableFuture.await()` and drives PacketEvents via `PacketEventsImplHelper.handleClientBoundPacket` with a synthetic `User`.
- `PacketCancellationIntegrationTest` sets the Bukkit player on the synthetic PacketEvents event so module listeners can match `PacketSendEvent#getPlayer`.
- Matrix task: `integrationTest` depends on `integrationTestMatrix`.
- `OCMTestMain` writes `plugins/OldCombatMechanicsTest/test-results.txt` containing `PASS` or `FAIL`.
## Version matrix and Java selection
- Config is in `build.gradle.kts`.
- `integrationTestVersions` comes from the 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<version>` downloads Mojang server jars for `<=1.12` and writes `run/<version>/cache/mojang_<version>.jar`.
- Paper 1.12 sometimes fails to download the legacy vanilla jar from old Mojang endpoints; the custom `downloadVanilla` task uses the v2 manifest.
## Kotlin runner split
- 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 `kernitus.plugin.OldCombatMechanics.LegacyTestRunner`.
- The Java 8 path is currently a smoke test only, verifying the plugin is enabled and `WeaponDamages` is loaded.
## Fake player notes
- Primary implementation: `src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt`.
- It uses `xyz.jpenilla:reflection-remapper` to map modern NMS names.
- On legacy servers, mappings are unavailable and code falls back to `ReflectionRemapper.noop()`.
- As-is, `FakePlayer` uses modern NMS class names; Paper 1.12 needs a dedicated fake-player path or version-aware class mapping.
- Fake player implementations use simulated login/network plumbing with `EmbeddedChannel` and manual login/join/quit events, not a real networked client.
- `FakePlayer` uses a plain `EmbeddedChannel` with dummy `decoder`/`encoder` handlers so PacketEvents treats it as fake and does not disallow login.
- Modern fake players schedule a manual NMS tick, preferring `doTick`, then `tick`, then `baseTick`.
- The tick shim invokes `baseTick` while burning to ensure fire tick damage events still occur on Paper 1.21+, and prefers normal ticking when in water so extinguish logic can run.
- `FakePlayer` prefers `PlayerList.placeNewPlayer` and forces a world add when the Bukkit world does not report the fake player entity after placement.
- `FakePlayer` clears invulnerability/instabuild abilities after spawn, plus a legacy fallback.
## Test harness shortcuts
- Synthetic event tests: `GoldenAppleIntegrationTest`, `OldPotionEffectsIntegrationTest`, `OldArmourDurabilityIntegrationTest`, `PlayerKnockbackIntegrationTest`, `SwordBlockingIntegrationTest`, and `SwordSweepIntegrationTest`.
- Direct module handler tests: `PlayerKnockbackIntegrationTest` and `SwordSweepIntegrationTest`.
- `AttributeModifierCompat` synthesises a fallback attack-damage modifier from `NewWeaponDamage` when API attributes are missing.
## Registration and notable test history
- `KotestRunner` must include each new integration test class explicitly.
- `OldToolDamageMobIntegrationTest`, `WeaponDurabilityIntegrationTest`, `DisableOffhandIntegrationTest`, `ToolDamageTooltipIntegrationTest`, `PacketCancellationIntegrationTest`, `ModesetRulesIntegrationTest`, `ConfigMigrationIntegrationTest`, `ChorusFruitIntegrationTest`, `DisableOffhandReflectionIntegrationTest`, `AttackRangeIntegrationTest`, `PaperSwordBlockingDamageReductionIntegrationTest`, and `ConsumableComponentIntegrationTest` have all been registered historically.
- `Kotest` filters (`kotest.filter.specs`, `kotest.filter.tests`) pass through Gradle into the run-paper JVM args.
- `WeaponDurabilityIntegrationTest` and `OldToolDamageMobIntegrationTest` resolve debug paths relative to the repo root rather than hard-coded home paths.
- `1.9` integration tests are currently on hold per user request.
- 1.21.11 servers may log hostname and Unsafe warnings; tests can still pass.
## PacketEvents and packet cancellation history
- `ModuleSwordSweepParticles` and `ModuleAttackSounds` use PacketEvents listeners/wrappers instead of ProtocolLib.
- `PacketCancellationIntegrationTest` builds a `PacketSendEvent` directly from the transformed buffer, sets packet id/type explicitly, and dispatches it through the PacketEvents event manager.
- PacketEvents dependency moved to `2.11.2-SNAPSHOT` from CodeMC snapshots for 1.21.11 support.
## Sword blocking and consumable component history
- Paper-only sword blocking uses runtime-gated helper `kernitus.plugin.OldCombatMechanics.paper.PaperSwordBlocking` to apply consumable and blocking components via cached reflection.
- 1.8-style reduction is applied via `EntityDamageByEntityListener` using the `BLOCKING` modifier, while legacy/non-Paper keeps the shield swap path.
- `ModuleSwordBlocking` gates the Paper consumable animation by PacketEvents client version `>=1.20.5`; older clients fall back to offhand shield behaviour.
- Unknown PacketEvents client versions fall back to shield behaviour only when a PacketEvents `User` exists; early-login/synthetic players keep animation support unless resolver failure requires fail-safe fallback.
- Inventory-click and drag handlers follow a minimal-mutation policy; lifecycle and transition handlers clean stale consumable state.
- Lifecycle cleanup covers modeset change, reload, world change, quit, join, held-slot changes, and swap paths.
- `sword-blocking.paper-animation` can hard-disable the Paper animation path and force legacy shield fallback.
- Legacy shield-drop reconciliation is safety-first and avoids guessing plain shield drops as temporary shields when marker APIs are unavailable.
## Fire aspect and fire tick notes
- `FireAspectOverdamageIntegrationTest` uses a Zombie victim for real fire tick sampling with boosted max health.
- Initial tests fire synthetic `EntityDamageEvent` with `FIRE_TICK` for deterministic baseline checks.
- Paper 1.12 applies attack-cooldown scaling before Bukkit damage events; tests wait a few ticks before the first attack to stabilise the first hit.
- Extra coverage includes fire resistance, fire-immune victims, water extinguish behaviour, fire protection versus protection, and alternating attackers during invulnerability.
- Legacy player-specific fire tick sampling differs, so player afterburn-vs-environment comparisons are no-ops on legacy; Zombie variants still run across versions.
## Miscellaneous historical fixes
- Removed dead reflection utility `ClassType` and unused `Reflector#getClass(ClassType, String)` overload.
- `SpigotFunctionChooser` now only falls back for compatibility-style failures and rethrows ordinary runtime logic failures.
- `AttackCompat` requires observable living-target hit signals for Bukkit `Player#attack` success and treats boolean-return NMS methods returning `false` as failed attempts.
- Several modules replaced per-player/per-hit scheduled tasks with shared or lazy-pruned maps: sword blocking dedupe, fishing rod velocity, attack cooldown tracking, player knockback, shield damage reduction, old armour durability, player regen, disable enderpearl cooldown, and stored last-damage expiry.
- `ModuleLoader` clears the static module list on initialise to prevent duplicate registrations after hot reloads.
- `README` includes licence notes: source remains MPL-2.0, while pre-built jars bundling PacketEvents are distributed under GPLv3; builds without PacketEvents can remain MPL-2.0.
@@ -0,0 +1,63 @@
---
name: module-config-change
description: Use for config.yml, module enablement, modesets, config migration, configurable module assignment, and per-module option changes; do not use for unrelated integration-test triage, release publishing, or PR summaries.
---
# Module Config Change
Use this skill when changing module configuration, defaults, migration, or runtime handling driven by `config.yml`.
## When to use
- Editing bundled `config.yml` defaults or comments.
- Adding or changing module settings such as attack cooldown, attack range, sword blocking, old tool damage, tooltip, potion effects, or durability behaviour.
- Changing `always_enabled_modules`, `disabled_modules`, modesets, or config migration logic.
- Validating reload behaviour after config changes.
- Writing tests around config assignment, migration, or modeset-specific behaviour.
## When not to use
- Do not use for ordinary test execution unless the test is config-specific; use `integration-test-verification`.
- Do not use for Java/NMS compatibility strategy unless the config option depends on runtime feature detection; use `compatibility-strategy` as well.
- Do not use for release workflow edits; use `release-readiness-review`.
## Mandatory configuration rules
- Configurable modules must appear in only one top-level assignment category: `always_enabled_modules`, `disabled_modules`, or the aggregate `modesets` category. The same module may appear in more than one individual modeset because modesets are alternative player modes.
- Duplicate entries are still invalid within the same exact list or individual modeset.
- Internal modules (`modeset-listener`, `attack-cooldown-tracker`, `entity-damage-listener`) are always enabled and must not be listed.
- Reload/enable should fail for invalid module assignment.
- Do not add per-module `enabled:` toggles; the module lists and modesets are authoritative.
- Keep bundled comments clear for server owners and use British English.
## Change checklist
1. Identify whether the change is a default, migration, reload, or runtime behaviour change.
2. Update bundled config comments and defaults together when needed.
3. Preserve existing user config where migration should be non-destructive.
4. Run `scripts/validate-module-assignments.sh` for a quick static check that bundled configurable lists do not conflict across `always_enabled_modules`, `disabled_modules`, and the aggregate `modesets` category; do not duplicate entries within the same exact list or modeset; and do not include internal modules.
5. Add or update integration coverage where practical.
6. If adding an integration spec, register it in `KotestRunner.withClasses(...)`.
7. Validate both enabled and disabled/modeset-scoped behaviour for options that change item state or player state.
## Useful known behaviours
- `old-tool-damage.tooltip.enabled` is enabled by default so players can see configured damage in-game.
- `attack-range` is listed in `disabled_modules` by default and is Paper 1.21.11+ only.
- `sword-blocking.paper-animation` defaults to `true`; when false, sword blocking uses the legacy shield fallback and strips stale Paper consumable state.
- Attack cooldown supports `disable-attack-cooldown.held-item-attack-speeds.<MATERIAL>` with XMaterial/Material matching and restores vanilla attack speed when disabled.
## Validation template
```text
Config area:
Default changed:
Migration needed:
Enabled-path validation:
Disabled/modeset validation:
KotestRunner registration needed:
```
## References
- Historical integration and module notes: `../integration-test-verification/references/relocated-agents-notes.md`.
+66
View File
@@ -0,0 +1,66 @@
---
name: pr-draft-summary
description: Use when drafting pull-request titles, descriptions, change summaries, risk notes, validation sections, or reviewer handoff text; do not use for implementation design, release publishing checks, or raw test failure triage.
---
# PR Draft Summary
Use this skill to turn completed work into reviewer-facing prose. Keep wording concise, accurate, and in British English.
## When to use
- Drafting a PR title or body.
- Summarising changes after a slice or branch of work.
- Writing reviewer notes, risk notes, validation sections, or follow-up items.
- Turning technical details into a changelog-style summary.
## When not to use
- Do not use before implementation decisions are made; use the relevant implementation skill first.
- Do not use for release readiness unless the requested output is a PR description; use `release-readiness-review` for release checks.
- Do not use to interpret raw integration test failures; use `integration-test-verification`.
## Preferred structure
```markdown
## Summary
-
-
## Validation
-
## Risks / notes
-
```
## Conventional commit prompt
When asked for a commit message, use conventional commits format, for example:
```text
docs: add repo-local agent skill guidance
```
## Reviewer handoff checklist
- Name the changed area before the mechanism.
- Mention validation commands exactly as run.
- If validation was not run, say why.
- Separate behaviour changes from documentation/configuration changes.
- Avoid overstating coverage; note known gaps plainly.
## Example phrasing
```markdown
## Summary
- Added repo-local agent skills for integration-test verification, compatibility strategy, module config changes, release readiness, and PR summaries.
- Slimmed root `AGENTS.md` to always-on rules plus routing guidance, with historical details relocated into skill references.
## Validation
- Checked skill frontmatter and folder-name matches.
- Confirmed no plugin runtime files changed.
## Risks / notes
- opencode must be restarted before new or changed skills are available in a running session.
```
@@ -0,0 +1,56 @@
---
name: release-readiness-review
description: Use for GitHub release, Hangar, CurseForge/BukkitDev upload, licence, asset naming, supported-version, and workflow readiness checks; do not use for day-to-day feature implementation, test authoring, or PR prose only.
---
# Release Readiness Review
Use this skill before changing or reviewing release workflows, publishing metadata, supported game versions, licence notes, and final release assets.
## When to use
- Editing `.github/workflows/*release*` or publishing-related Gradle configuration.
- Checking Hangar, CurseForge/BukkitDev, or GitHub release upload behaviour.
- Reviewing licence implications of shaded dependencies such as PacketEvents.
- Updating supported Minecraft versions, `gradle.properties` release metadata, or asset names.
- Preparing a release-readiness checklist for maintainers.
## When not to use
- Do not use for ordinary implementation or test selection; use the feature-specific skill.
- Do not use for PR description wording unless release notes are the main task; use `pr-draft-summary`.
- Do not use for module config behaviour unless it affects packaging or documented release defaults.
## Current release notes
- GitHub release asset uses stable filename `OldCombatMechanics.jar` without a version suffix.
- CurseForge upload uses the same path.
- Bukkit-compatible CurseForge game-version entries use `1:<version>` prefixes so type-1 Bukkit versions are selected.
- Hangar publish configuration expects `HANGAR_API_TOKEN`.
- README licence note: source remains MPL-2.0; pre-built jars bundling PacketEvents are distributed under GPLv3; builds without PacketEvents can remain MPL-2.0.
- `gradle.properties` game versions currently include 1.21.11 down to 1.21.1, plus 1.21, ahead of existing entries.
## Review checklist
1. Confirm secrets are referenced by environment variable or GitHub secret name, never hard-coded.
2. Confirm the built jar path and uploaded asset path match.
3. Confirm supported game versions map to the publishing platform’s expected identifiers.
4. Confirm shaded dependency licence implications are reflected in release notes or README where needed.
5. Confirm workflow changes do not alter plugin runtime behaviour.
6. Prefer dry, static checks unless the user explicitly requests a release run.
## Release note template
```markdown
### Release readiness
- Asset: `OldCombatMechanics.jar`
- Platforms checked: GitHub / Hangar / CurseForge
- Version metadata checked:
- Licence notes checked:
- Secrets touched: none / list variable names only
- Remaining manual steps:
```
## References
- Broader historical notes: `../integration-test-verification/references/relocated-agents-notes.md`.
+642
View File
@@ -0,0 +1,642 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
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
+1
View File
@@ -0,0 +1 @@
.gitea/CODEOWNERS linguist-detectable=false
+3
View File
@@ -0,0 +1,3 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
+55 -28
View File
@@ -1,30 +1,57 @@
# 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.
<!--
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/.
-->
## Formatting profiles
### IntelliJ
```xml
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="JAVA">
<option name="SPACE_BEFORE_IF_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_WHILE_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_FOR_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_TRY_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_CATCH_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_SWITCH_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_SYNCHRONIZED_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_METHOD_LBRACE" value="false" />
<option name="SPACE_BEFORE_IF_LBRACE" value="false" />
<option name="SPACE_BEFORE_WHILE_LBRACE" value="false" />
<option name="SPACE_BEFORE_FOR_LBRACE" value="false" />
<option name="SPACE_BEFORE_SWITCH_LBRACE" value="false" />
<option name="SPACE_BEFORE_TRY_LBRACE" value="false" />
<option name="SPACE_BEFORE_CATCH_LBRACE" value="false" />
<option name="SPACE_BEFORE_SYNCHRONIZED_LBRACE" value="false" />
</codeStyleSettings>
</code_scheme>
</component>
# Contributing
All contributions should be in the form of [pull requests](https://github.com/kernitus/BukkitOldCombatMechanics/pulls), and should follow the formatting guidance below. 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.
## Local setup
Install [pre-commit](https://pre-commit.com/) with your preferred Python package manager, for example:
```shell
pipx install pre-commit
```
### Eclipse
If you are using Eclipse, we would welcome someone to port our formatter profile over to Eclipse. If you would like to do so, please open an issue with the Eclipse formatter profile, or DM Rayzr522#9429 on Discord with the profile.
Then install and test the repository hooks:
```shell
pre-commit install
pre-commit run --all-files
```
Before committing or opening a pull request, run:
```shell
./gradlew spotlessCheck
./gradlew check
```
Also run the relevant integration tests when behaviour, compatibility, or bug-fix work warrants them, such as `./gradlew integrationTest` or a targeted task like `./gradlew integrationTest1_19_2`.
## 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
The repository root `.editorconfig` is the source of truth for IntelliJ and EditorConfig-aware editors. Use `./gradlew spotlessCheck` to verify formatting and `./gradlew spotlessApply` to apply formatting fixes.
Java is not formatted with Google Java Format. Continue to follow the repository IntelliJ/EditorConfig style for Java changes.
-14
View File
@@ -1,14 +0,0 @@
<!-- Before reporting, please try the latest test version from https://ci.rayzr.dev/job/OldCombatMechanics/ as often bugs have already been fixed there. ->
<!-- Please fill out all fields as applicable, providing as many details as possible -->
## Info
<!-- For the last two, please post links to a pastebin.com upload of the file -->
- Server version:
- OldCombatMechanics version:
- Server log file:
- OldCombatMechanics config file:
## Problem Description
>
+24
View File
@@ -0,0 +1,24 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(deps)"
ignore:
- dependency-name: "io.netty:netty-all"
# Kotest 6 requires JDK/JVM 11+, while legacy integration-test servers still
# load the test plugin on Java 8; keep Kotest 5.x updates allowed until the
# harness is split or migrated.
- dependency-name: "io.kotest:kotest-runner-junit5-jvm"
versions: ["[6,)"]
- dependency-name: "io.kotest:kotest-assertions-core-jvm"
versions: ["[6,)"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(deps)"
+1
View File
@@ -0,0 +1 @@
{".":"2.5.0"}
@@ -0,0 +1,91 @@
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@v6
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: wrapper
- name: Run Spotless Check
run: |
./gradlew spotlessCheck
# 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@v3
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
+58
View File
@@ -0,0 +1,58 @@
name: Dev builds
on:
push:
branches:
- master
pull_request:
branches-ignore:
- 'ingametesting'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
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 Spotless Check
run: |
./gradlew spotlessCheck
- name: Run Gradle Build
run: |
./gradlew clean build
- name: Archive jar file
uses: actions/upload-artifact@v7
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
+24
View File
@@ -0,0 +1,24 @@
name: Release Please
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: release-please
uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/release-please-config.json
manifest-file: .github/release-please-manifest.json
@@ -0,0 +1,63 @@
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@v9
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}` +
`<details>\n` +
`<summary>${summaryText}</summary>\n\n` +
`${codeblock}\n\n` +
`</details>`
);
});
}
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
});
+10 -1
View File
@@ -19,4 +19,13 @@ out
builds
target
META-INF
dependency-reduced-pom.xml
build/
.kotlin/
.gradle/
.gradle-user/
.gradle-cache/
.gradle-local/
kls-classpath
kls_database.db
run/
bin/
+9
View File
@@ -0,0 +1,9 @@
repos:
- repo: local
hooks:
- id: spotless-check
name: Spotless check
entry: ./gradlew spotlessCheck
language: system
pass_filenames: false
require_serial: true
+517
View File
@@ -0,0 +1,517 @@
# OldCombatMechanics API
The OldCombatMechanics API exposes both configured modesets and temporary per-player module overrides. Prefer modesets for arena and minigame combat modes, such as offering separate 1.8-style and modern PvP experiences. Modesets are named configured bundles, selected per player and world, and changes made through `setModesetForPlayer` use the normal player-data persistence.
Module overrides are different: they are temporary API-only per-player exceptions for individual modules, intended for narrow runtime cases where a plugin needs to force one module on or off for one currently online player. Overrides are online-session-only, in-memory state. They are cleared when the player quits, when OldCombatMechanics is disabled, or when a plugin explicitly clears them through the API. Module overrides are not a replacement for configured modesets.
| Use case | Modesets | Module overrides |
| --- | --- | --- |
| Arena/minigame combat style, such as 1.8-style versus modern PvP | Preferred: configure named bundles and select them per player/world | Not intended for this |
| Persistence | Uses normal player-data persistence when changed through `setModesetForPlayer` | Online-session-only, in-memory |
| Scope | Whole configured bundle of module choices | Individual module exception for one player |
| Configuration source | Server configuration and allowed world modesets | API-only runtime state |
Overrides are shared runtime state. If multiple plugins set an override for the same player and module, the last write wins. Any plugin that clears that override clears the same shared state for that player and module.
The API also exposes configured modesets, world-allowed modesets, the player's stored modeset for their current world, modeset changes, and Bukkit events for module override and modeset changes.
---
## Dependency setup
There is currently no separate slim API artefact. Compile against the same OldCombatMechanics plugin jar that you deploy or test with, and mark it as compile-only/provided so your plugin does not shade or bundle OCM.
Gradle Kotlin DSL with a local jar:
```kotlin
dependencies {
compileOnly(files("libs/OldCombatMechanics.jar"))
compileOnly("org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
}
```
Maven with a local or system-provided jar:
```xml
<dependency>
<groupId>kernitus.plugin</groupId>
<artifactId>OldCombatMechanics</artifactId>
<version>your-ocm-version</version>
<scope>provided</scope>
</dependency>
```
If you use a local Maven repository or an internal repository manager, publish the OCM jar there and keep the dependency `provided`/`compileOnly`.
Do not use OCM internal packages. Public API classes live under:
```java
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent;
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride;
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent;
```
---
## `plugin.yml` load order
Use `depend` when your plugin cannot enable or function without OCM:
```yaml
depend: [OldCombatMechanics]
```
Use `softdepend` when OCM integration is optional and your plugin can run without it:
```yaml
softdepend: [OldCombatMechanics]
```
With `softdepend`, always treat the API service as optional and disable only your OCM-specific integration if the service is absent.
---
## Getting the API instance
Always null-check the Bukkit service registration before using the API. The service is only available while OldCombatMechanics is loaded and enabled.
```java
RegisteredServiceProvider<OldCombatMechanicsAPI> registration = Bukkit.getServicesManager()
.getRegistration(OldCombatMechanicsAPI.class);
if (registration == null) {
return;
}
OldCombatMechanicsAPI api = registration.getProvider();
```
Kotlin callers should use the same service lookup and null-check the registration:
```kotlin
val api = Bukkit.getServicesManager()
.getRegistration(OldCombatMechanicsAPI::class.java)
?.provider
?: return
```
---
## Threading
Call API methods from the Bukkit main server thread. The current contract does not guarantee async-safe access to Bukkit `Player` state, module state-change notifications, or future implementation details unless a future method explicitly documents different threading behaviour.
---
## Module names
All methods that accept a `moduleName` parameter expect a value from the set returned by [`getModuleNames()`](#getmodulenames). Only configurable modules are valid. Internal modules, such as listener/helper modules, cannot be overridden and are not returned by `getModuleNames()`.
Passing an unknown or non-configurable module name throws `IllegalArgumentException`.
---
## Effective module precedence
When OldCombatMechanics determines whether a configurable module is enabled for a player, it resolves state in this order:
1. per-player `FORCE_ENABLED` or `FORCE_DISABLED` override;
2. configured disabled modules;
3. configured always-enabled modules;
4. player modeset and world configuration.
`PlayerModuleOverride.DEFAULT` means no per-player override is set, so the configured rules above continue to decide the module state.
---
## Methods
### `getModesetNames`
Returns all configured modeset names in config iteration order.
```java
Set<String> getModesetNames()
```
```java
for (String modeset : api.getModesetNames()) {
player.sendMessage(modeset);
}
```
---
### `getAllowedModesets`
Returns the modesets allowed in a world as an unmodifiable defensive copy.
```java
Set<String> getAllowedModesets(World world)
```
Iteration order is meaningful: it follows the configured world list when present, the `worlds.__default__` list for worlds without their own list, or the configured modeset order when no world list applies.
There is no public API for a default or fallback modeset. If you need to present a default choice, use your own policy, such as the player's stored modeset when present or the first entry from `getAllowedModesets(world)`.
```java
Set<String> allowed = api.getAllowedModesets(player.getWorld());
for (String modeset : allowed) {
player.sendMessage("Allowed here: " + modeset);
}
```
---
### `getModesetForPlayer`
Returns the stored modeset for the player in their current world, or `null` when no player-specific modeset has been stored yet.
```java
String getModesetForPlayer(Player player)
```
```java
String modeset = api.getModesetForPlayer(player);
if (modeset == null) {
player.sendMessage("No modeset selected for this world.");
} else {
player.sendMessage("Current modeset: " + modeset);
}
```
---
### `setModesetForPlayer`
Stores the modeset for the player in their current world and fires `PlayerModesetChangeEvent` with reason `API` when the stored value changes.
```java
void setModesetForPlayer(Player player, String modesetName)
```
The input name is normalised before validation and storage. Unknown modesets, or modesets not allowed in the player's current world, throw `IllegalArgumentException`.
Modeset changes made through the API follow normal player data persistence semantics: OCM stores the selected modeset in player data and schedules the usual save. They are not session-only overrides.
```java
if (api.getAllowedModesets(player.getWorld()).contains("arena")) {
api.setModesetForPlayer(player, "arena");
}
```
---
### `forceEnableModuleForPlayer`
Forces a configurable module on for an online player until the override is cleared, the player quits, or OldCombatMechanics is disabled.
```java
void forceEnableModuleForPlayer(Player player, String moduleName)
```
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
```java
api.forceEnableModuleForPlayer(player, "attack-frequency");
```
Kotlin:
```kotlin
api.forceEnableModuleForPlayer(player, "attack-frequency")
```
---
### `forceDisableModuleForPlayer`
Forces a configurable module off for an online player until the override is cleared, the player quits, or OldCombatMechanics is disabled.
```java
void forceDisableModuleForPlayer(Player player, String moduleName)
```
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
```java
api.forceDisableModuleForPlayer(player, "sword-blocking");
```
Kotlin:
```kotlin
api.forceDisableModuleForPlayer(player, "sword-blocking")
```
---
### `clearModuleOverrideForPlayer`
Clears the override for a single configurable module, reverting the player to configured module behaviour. No-op if no override is set.
```java
void clearModuleOverrideForPlayer(Player player, String moduleName)
```
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
```java
api.clearModuleOverrideForPlayer(player, "attack-frequency");
```
Kotlin:
```kotlin
api.clearModuleOverrideForPlayer(player, "attack-frequency")
```
---
### `clearAllModuleOverridesForPlayer`
Clears all module overrides for a player at once, reverting them to configured module behaviour for every module. No-op if no overrides are set.
```java
void clearAllModuleOverridesForPlayer(Player player)
```
```java
api.clearAllModuleOverridesForPlayer(player);
```
Kotlin:
```kotlin
api.clearAllModuleOverridesForPlayer(player)
```
---
### `getModuleOverrideForPlayer`
Returns the current override state for a single configurable module for a player. Returns `PlayerModuleOverride.DEFAULT` if no override is set.
```java
PlayerModuleOverride getModuleOverrideForPlayer(Player player, String moduleName)
```
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
```java
PlayerModuleOverride override = api.getModuleOverrideForPlayer(player, "sword-blocking");
if (override == PlayerModuleOverride.FORCE_ENABLED) {
player.sendMessage("Forced on");
} else if (override == PlayerModuleOverride.FORCE_DISABLED) {
player.sendMessage("Forced off");
} else {
player.sendMessage("Following configured rules");
}
```
Kotlin:
```kotlin
when (api.getModuleOverrideForPlayer(player, "sword-blocking")) {
PlayerModuleOverride.FORCE_ENABLED -> player.sendMessage("Forced on")
PlayerModuleOverride.FORCE_DISABLED -> player.sendMessage("Forced off")
PlayerModuleOverride.DEFAULT -> player.sendMessage("Following configured rules")
}
```
---
### `getModuleOverridesForPlayer`
Returns all active overrides for a player as a map of module name to `PlayerModuleOverride`. Only modules with a non-`DEFAULT` override are included.
```java
Map<String, PlayerModuleOverride> getModuleOverridesForPlayer(Player player)
```
```java
Map<String, PlayerModuleOverride> overrides = api.getModuleOverridesForPlayer(player);
overrides.forEach((module, override) ->
player.sendMessage(module + ": " + override)
);
```
Kotlin:
```kotlin
api.getModuleOverridesForPlayer(player).forEach { (module, override) ->
player.sendMessage("$module: $override")
}
```
---
### `setModuleOverridesForPlayer`
Sets multiple module overrides for a player at once. Entries with `PlayerModuleOverride.DEFAULT` are treated as clears. All module names and override values are validated before any override state changes, so an invalid entry leaves existing overrides unchanged.
```java
void setModuleOverridesForPlayer(Player player, Map<String, PlayerModuleOverride> overrides)
```
**Throws:** `IllegalArgumentException` if any module name is unknown or non-configurable, or if a Java caller supplies a null module name or override value.
```java
Map<String, PlayerModuleOverride> overrides = new HashMap<>();
overrides.put("attack-frequency", PlayerModuleOverride.FORCE_ENABLED);
overrides.put("sword-blocking", PlayerModuleOverride.FORCE_ENABLED);
overrides.put("disable-offhand", PlayerModuleOverride.FORCE_DISABLED);
api.setModuleOverridesForPlayer(player, overrides);
```
Kotlin:
```kotlin
api.setModuleOverridesForPlayer(
player,
mapOf(
"attack-frequency" to PlayerModuleOverride.FORCE_ENABLED,
"sword-blocking" to PlayerModuleOverride.FORCE_ENABLED,
"disable-offhand" to PlayerModuleOverride.FORCE_DISABLED,
),
)
```
---
### `isModuleEnabledForPlayer`
Returns whether a configurable module is effectively active for a player, accounting for configured rules and any per-player override.
```java
boolean isModuleEnabledForPlayer(Player player, String moduleName)
```
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
```java
if (api.isModuleEnabledForPlayer(player, "attack-frequency")) {
// module is active for this player
}
```
Kotlin:
```kotlin
if (api.isModuleEnabledForPlayer(player, "attack-frequency")) {
// module is active for this player
}
```
---
### `hasAnyOverrideForPlayer`
Returns whether a player has any non-default override set.
```java
boolean hasAnyOverrideForPlayer(Player player)
```
```java
if (api.hasAnyOverrideForPlayer(player)) {
player.sendMessage("You have active overrides.");
}
```
Kotlin:
```kotlin
if (api.hasAnyOverrideForPlayer(player)) {
player.sendMessage("You have active overrides.")
}
```
---
### `getModuleNames`
Returns the names of all configurable modules that support per-player overrides. Only names in this set are valid inputs for the other API methods.
```java
Set<String> getModuleNames()
```
```java
for (String name : api.getModuleNames()) {
player.sendMessage(name);
}
```
Kotlin:
```kotlin
api.getModuleNames().forEach { name ->
player.sendMessage(name)
}
```
---
## `PlayerModuleOverride`
| Value | Description |
|------------------|----------------------------------------------------------------------------------------|
| `DEFAULT` | No per-player override; the player follows configured module and modeset rules. |
| `FORCE_ENABLED` | Module is forced on for the player before configured disabled modules are considered. |
| `FORCE_DISABLED` | Module is forced off for the player before configured enabled modules are considered. |
---
## Events
Register event listeners with Bukkit as usual. Events are fired after OCM changes the stored API state and before it reapplies module state for the affected player.
### `PlayerModesetChangeEvent`
Fired when a player's stored modeset changes. Reasons are `API`, `COMMAND`, `WORLD_CHANGE`, and `JOIN`.
```java
@EventHandler
public void onModesetChange(PlayerModesetChangeEvent event) {
Player player = event.getPlayer();
String previous = event.getPreviousModeset();
String current = event.getNewModeset();
player.sendMessage("Modeset changed from " + previous + " to " + current
+ " because of " + event.getReason());
}
```
`getPreviousModeset()` and `getNewModeset()` can return `null` when no modeset was previously stored or no replacement is available.
### `PlayerModuleOverrideChangeEvent`
Fired when a player's per-player module override changes.
```java
@EventHandler
public void onModuleOverrideChange(PlayerModuleOverrideChangeEvent event) {
if (event.getNewOverride() == PlayerModuleOverride.DEFAULT) {
event.getPlayer().sendMessage(event.getModuleName() + " is following configured rules again.");
return;
}
event.getPlayer().sendMessage(event.getModuleName() + " changed from "
+ event.getPreviousOverride() + " to " + event.getNewOverride());
}
```
---
## Java example
```java
public class ArenaCombatManager {
private final OldCombatMechanicsAPI api;
private static final Map<String, PlayerModuleOverride> ARENA_OVERRIDES;
static {
ARENA_OVERRIDES = new HashMap<>();
ARENA_OVERRIDES.put("attack-frequency", PlayerModuleOverride.FORCE_ENABLED);
ARENA_OVERRIDES.put("sword-blocking", PlayerModuleOverride.FORCE_ENABLED);
ARENA_OVERRIDES.put("disable-offhand", PlayerModuleOverride.FORCE_ENABLED);
}
public ArenaCombatManager(OldCombatMechanicsAPI api) {
this.api = api;
}
public void onPlayerJoinArena(Player player) {
api.setModuleOverridesForPlayer(player, ARENA_OVERRIDES);
}
public void onPlayerLeaveArena(Player player) {
api.clearAllModuleOverridesForPlayer(player);
}
}
```
## Kotlin example
```kotlin
class ArenaCombatManager(private val api: OldCombatMechanicsAPI) {
private val arenaOverrides = mapOf(
"attack-frequency" to PlayerModuleOverride.FORCE_ENABLED,
"sword-blocking" to PlayerModuleOverride.FORCE_ENABLED,
"disable-offhand" to PlayerModuleOverride.FORCE_ENABLED,
)
fun onPlayerJoinArena(player: Player) {
api.setModuleOverridesForPlayer(player, arenaOverrides)
}
fun onPlayerLeaveArena(player: Player) {
api.clearAllModuleOverridesForPlayer(player)
}
}
```
+162
View File
@@ -0,0 +1,162 @@
# Changelog
## [2.5.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.4.0...v2.5.0) (2026-05-23)
### Features
* add per-player module override api ([53780bc](https://github.com/kernitus/BukkitOldCombatMechanics/commit/53780bc3b286dd8a0bd1e685df114326c24db96d))
* add per-player module override api ([c2043e2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c2043e23e064b2893f8ee32c113191f6952d639f))
* **api:** expose per-player modeset controls ([b3a6039](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b3a603991814fe6840014d9949a995cb59b22d60))
* **api:** publish player state change events ([6f33807](https://github.com/kernitus/BukkitOldCombatMechanics/commit/6f338073663e2720b5c7a390f986246ee82545ef))
* improve API & introduce API.md ([a091b06](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a091b0688982fffc6b2fb862ca5750f4575adf38))
* migrate default world modesets ([8e9ee90](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8e9ee90cec78c34034b4bfddec1a529f732b2cab))
* per-item cooldown ([890c41f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/890c41f442ed2ba5570096856b206829c1159ea8)), closes [#856](https://github.com/kernitus/BukkitOldCombatMechanics/issues/856)
* rewrite in kotlin ([d4eee15](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d4eee15c54952a68a8c3635b497835d4fd969165))
* show build commit in version output ([e20fe98](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e20fe988e9cc3fb3f174fe50cf8e8941d465232f))
* support default world modesets ([7d3b417](https://github.com/kernitus/BukkitOldCombatMechanics/commit/7d3b41707bb879b81e753182292a7c51a2e9ee99))
### Bug Fixes
* **armour:** apply mixed-mode armour strength by defender ([f32671b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f32671bc02c71da8978a5e5d33513b995ccc1ea0))
* **armour:** respect modesets for explosion defence ([#861](https://github.com/kernitus/BukkitOldCombatMechanics/issues/861)) ([729c311](https://github.com/kernitus/BukkitOldCombatMechanics/commit/729c311e6e77df42281084e0a3a276376c834050))
* **attack-range:** detect Paper data component support ([b12829d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b12829d7c300ad5c516bb481e3a28c8c2653d723))
* **compat:** avoid hot-path optional API reflection ([8f14b8b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8f14b8b8e2f7d8d1e71a059e85c4bebf20f899a8))
* **compat:** clarify semantic version gates ([b5341f3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b5341f3898308f34b4aa8ca81ac91d8695cb6158))
* **compat:** detect API capabilities for version gates ([bfbb2e4](https://github.com/kernitus/BukkitOldCombatMechanics/commit/bfbb2e420ffeeb6665a1c73afb6f66eb32265de7))
* **damage:** respect bypasses_cooldown damage types [#864](https://github.com/kernitus/BukkitOldCombatMechanics/issues/864) ([83a7154](https://github.com/kernitus/BukkitOldCombatMechanics/commit/83a7154d8cea60265be6ea46acb0165d1767ce68))
* **modesets:** correct stale stored world modesets ([#865](https://github.com/kernitus/BukkitOldCombatMechanics/issues/865)) ([b0f451d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b0f451d7de4a30e40047f4a382da77ffeef6127d))
* **reflection:** compare Minecraft versions lexicographically ([67e266c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/67e266c938dc51fef17b68ba90ef29785c69e9f9))
* respect MythicMobs no-knockback damage ([#860](https://github.com/kernitus/BukkitOldCombatMechanics/issues/860)) ([a25e0ed](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a25e0ed24c87477f6ae912230568c323d55ed576))
* **shields:** apply mixed-mode blocking by defender ([20249db](https://github.com/kernitus/BukkitOldCombatMechanics/commit/20249db1ee46875494f96942890ed318ec7101f0))
* **tool-damage:** apply mixed-mode weapon damage by attacker ([eb905f2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/eb905f21b8eabed8ad2029d352ff99cb7978a287))
* validate module override bulk updates ([c6e14ba](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c6e14bac3dd270738572064d101ad1acd73a5636))
## [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 &gt;=1.20 ([f502adb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f502adbbece03875e491bd1f39e4a45d1f498802))
* weakness calculations for amplifier &gt; 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 &gt;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 &lt;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)
+373
View File
@@ -0,0 +1,373 @@
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.
+119 -48
View File
@@ -1,60 +1,131 @@
<!--
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/.
-->
<p align="center">
<img src="res/ocm-icon.png" width=320>
<img src="res/ocm-banner.png" width=1000>
</p>
## 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. It is built for PvP networks, minigames, BedWars/SkyWars-style games, duels, KitPvP, practice arenas, and mixed PvP/PvE worlds that need different combat rules in different places.
**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 PvP networks, BedWars/SkyWars-style minigames, duels, KitPvP, FFA, practice arenas, and mixed PvP/PvE.
- 🔌 **Plugin-friendly API:** integrate OCM with game logic by applying session-only per-player module overrides, managing player modesets, and checking whether modules are effectively enabled.
- ⏪ **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 <name>` to let them choose their ruleset.
<p align="center">
<a href="https://hangar.papermc.io/kernitus/OldCombatMechanics">
<img src="res/paper.png" alt="Paper" height="100">
</a>
<a href="https://www.spigotmc.org/resources/19510/">
<img src="res/spigot.png" alt="Spigot" height="100">
</a>
<a href="https://dev.bukkit.org/projects/oldcombatmechanics">
<img src="res/bukkit.png" alt="Bukkit" height="100">
</a>
</p>
<hr/>
# Old Combat Mechanics
#### by kernitus and Rayzr522
A Bukkit/Spigot plugin to revert combat mechanics to pre-1.9. Also supports reverting other features.
## 🧰 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 <name>` ) to run, for example, 1.8-style PvP in an arena world while keeping vanilla rules in survival.
## 🏟 Built for minigames and PvP networks
- Run old-style combat in competitive arenas while keeping modern mechanics in lobbies, survival, or event worlds.
- Tailor rules for BedWars, SkyWars, duels, KitPvP, FFA, practice arenas, tournaments, and temporary events without splitting your network across extra plugins.
- Keep configuration readable for staff: combine world defaults, player-selected modesets, and targeted plugin overrides for special match phases.
## 🔌 Developer API
- OCM registers its API through Bukkit's services manager, so other plugins can look it up without depending on internal implementation classes.
- The current API supports session-only per-player module overrides, player modeset queries/changes, and events for reacting to OCM state changes.
- Plugins can also check whether a module is effectively enabled for a player after world rules, player choices, and overrides have been considered.
- Overrides are not persisted across restarts or reconnects; reapply them from your game state when needed.
- See [API.md](API.md) for dependency setup, `plugin.yml` guidance, method details, and Java/Kotlin examples.
## ⚙ 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, 1.21.11, and 26.1.2 each build to verify behaviour.
- Most plugins work fine with OCM. Targeted compatibility includes PlaceholderAPI (see [wiki](https://github.com/kernitus/BukkitOldCombatMechanics/wiki/PlaceholderAPI)) and MythicMobs no-knockback damage skills with old-player-knockback.
## 🧾 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).
## Features
- Master & per-world toggle for each module
- All modules fully customisable
- Remove weapon cooldown
- Setting tool damage as in pre-1.9
- Make players regenerate like in pre-1.9 & with correct exhaustion
- Give players a shield while they right click with a sword
- Have armour be as strong as in pre-1.9
- Have configurable old potion durations
- Have configurable old potion effects (strength & weakness)
- Disable sword sweep attack
- Option to disable shield crafting
- Disable off-hand (with white/blacklist)
- Make shields block 1/2 a heart instead of reduce damage to 33%
- Configurable general & projectile damage reduction for shields
- Have arrows not be stopped by shields
- Re-add snowball, egg & enderpearl knockback
- Disable player collisions
- Enchanted golden apple crafting
- Customisable enchanted & golden apple potion effects
- Fishing rods knockback and damage like in pre-1.9 (customisable)
- Automatically add blaze powder to brewing stands
- Automatically add lapis in enchanting tables
- Stop usage of elytra
- Disable random projectile trajectories
- Disable bow boosting
## Installation
To install OldCombatMechanics, please download the latest version from the [releases page](https://github.com/kernitus/BukkitOldCombatMechanics/releases) (or from [Bukkit](http://dev.bukkit.org/bukkit-plugins/oldcombatmechanics/) or [Spigot](https://www.spigotmc.org/resources/oldcombatmechanics.19510/)). Simply drop the JAR file in your plugins folder and restart your server.
Development versions are available on the [OldCombatMechanics Jenkins server](https://jenkinsgvl-jenkins-gvl.1d35.starter-us-east-1.openshiftapps.com/job/OldCombatMechanics).
<hr/>
<a href="https://www.spigotmc.org/resources/oldcombatmechanics.19510/">
<img src="https://media-elerium.cursecdn.com/attachments/202/435/spigot.png" alt="Spigot" height="100">
</a>
<a href="https://dev.bukkit.org/projects/oldcombatmechanics">
<img src="https://media-elerium.cursecdn.com/attachments/202/436/bukkit.png" alt="Bukkit" height="100">
</a>
<a href="https://ci.rayzr.dev/job/OldCombatMechanics/">
<img src="https://media-elerium.cursecdn.com/attachments/202/434/jenkins.png" alt="Jenkins" height="100">
</a>
<hr/>
![bStats](https://bstats.org/signatures/Bukkit/OldCombatMechanics.svg)
<a href="https://bstats.org/plugin/bukkit/OldCombatMechanics">
<img src="https://bstats.org/signatures/bukkit/OldCombatMechanics.svg" alt="bStats">
</a>
-24
View File
@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognise copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org>
+924
View File
@@ -0,0 +1,924 @@
/*
* 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.Closeable
import java.io.Serializable
import java.net.URI
import java.nio.file.Files
import java.security.MessageDigest
val paperVersion: List<String> =
(property("gameVersions") as String)
.split(",")
.map { it.trim() }
plugins {
`java-library`
kotlin("jvm") version "2.3.21"
id("com.diffplug.spotless") version "8.5.1"
id("com.gradleup.shadow") version "9.4.1"
id("xyz.jpenilla.run-paper") version "3.0.2"
eclipse
idea
id("io.papermc.hangar-publish-plugin") version "0.1.4"
}
spotless {
// Avoid a noisy first formatter rollout; enforce Spotless on changed files.
ratchetFrom("origin/master")
// Plugin package follows Java convention `kernitus.plugin.OldCombatMechanics.*` — renaming would break
// every consumer. Disable ktlint's lowercase-only package rule plugin-wide.
val ktlintOverrides =
mapOf(
"ktlint_standard_package-name" to "disabled",
"ktlint_standard_filename" to "disabled"
)
kotlin {
target("src/**/*.kt")
ktlint().editorConfigOverride(ktlintOverrides)
}
kotlinGradle {
target("*.gradle.kts", "gradle/**/*.gradle.kts")
ktlint().editorConfigOverride(ktlintOverrides)
endWithNewline()
}
format("misc") {
target("*.md", "*.yml", "*.yaml", "*.properties", ".editorconfig")
trimTrailingWhitespace()
endWithNewline()
}
}
// Auto-apply formatters before compile so cosmetic issues self-heal instead of failing the build.
tasks.named("compileKotlin") {
dependsOn("spotlessApply")
}
tasks.named("compileJava") {
dependsOn("spotlessApply")
}
tasks.named("check") {
dependsOn("spotlessCheck")
}
// 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" // 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
}
val apiSmokeTest by creating {
java.setSrcDirs(listOf("src/apiSmokeTest/java"))
resources.setSrcDirs(listOf("src/apiSmokeTest/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)
}
configurations.named("apiSmokeTestCompileClasspath") {
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
}
dependencies {
implementation("org.bstats:bstats-bukkit:3.2.1")
// Server-provided: intentionally pinned to the oldest supported runtime baseline
// (Minecraft 1.9.4 / Netty 4.0.23.Final) so compile-only code cannot use newer Netty APIs unavailable on legacy servers.
compileOnly("io.netty:netty-all:4.0.23.Final")
// Placeholder API
compileOnly("me.clip:placeholderapi:2.12.2")
// For BSON file serialisation
implementation("org.mongodb:bson:5.7.0")
// Spigot
compileOnly("org.spigotmc:spigot-api:26.1.2-R0.1-SNAPSHOT")
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.9.1")
testImplementation("io.kotest:kotest-assertions-core-jvm:5.9.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// 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.7.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.21")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.21")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-test:2.3.21")
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-reflect:2.3.21")
add("integrationTestImplementation", "io.kotest:kotest-runner-junit5-jvm:5.9.1")
add("integrationTestImplementation", "io.kotest:kotest-assertions-core-jvm:5.9.1")
add("integrationTestImplementation", "xyz.jpenilla:reflection-remapper:0.1.3")
add("integrationTestCompileOnly", "org.spigotmc:spigot-api:26.1.2-R0.1-SNAPSHOT")
add("integrationTestCompileOnly", "com.mojang:authlib:6.0.54")
add("integrationTestCompileOnly", "io.netty:netty-all:4.0.23.Final")
// Java-only API smoke plugin dependencies
add("apiSmokeTestCompileOnly", sourceSets.main.get().output)
add("apiSmokeTestCompileOnly", "org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
}
// Substitute ${pluginVersion} in plugin.yml with version defined above
class ExpandPluginVersionAction(private val version: String) :
Action<FileCopyDetails>,
Serializable {
override fun execute(details: FileCopyDetails) {
details.expand(mapOf("pluginVersion" to version))
}
}
class ExpandBuildCommitAction(private val commit: String) :
Action<FileCopyDetails>,
Serializable {
override fun execute(details: FileCopyDetails) {
details.expand(mapOf("buildCommit" to commit))
}
}
val pluginVersion = project.version.toString()
val buildCommit =
runCatching {
val process =
ProcessBuilder("git", "rev-parse", "--short", "HEAD")
.directory(rootDir)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val exitValue = process.waitFor()
if (exitValue == 0) {
output.trim()
} else {
""
}
}.getOrDefault("")
val expandPluginVersionAction = ExpandPluginVersionAction(pluginVersion)
val expandBuildCommitAction = ExpandBuildCommitAction(buildCommit)
tasks.named<Copy>("processResources") {
inputs.property("pluginVersion", pluginVersion)
inputs.property("buildCommit", buildCommit)
filesMatching("plugin.yml", expandPluginVersionAction)
filesMatching("ocm-build.properties", expandBuildCommitAction)
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.release.set(8)
}
tasks.test {
useJUnitPlatform()
}
tasks.jar {
// The runtime plugin is the shaded jar below; do not emit an unbundled plugin artefact.
enabled = false
}
val shadowJarTask =
tasks.named<ShadowJar>("shadowJar") {
dependsOn("jar")
archiveFileName.set("${project.name}.jar")
dependencies {
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<KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)
}
val relocateIntegrationTestClasses =
tasks.register<ShadowJar>("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<Jar>("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 apiSmokeTestJarTask =
tasks.register<Jar>("apiSmokeTestJar") {
archiveClassifier.set("api-smoke-test")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn("apiSmokeTestClasses")
from(sourceSets["apiSmokeTest"].output)
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, "26.1.2", "1.21.11", "1.12", "1.9.4")
.distinct()
val integrationTestVersions: List<String> =
(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
val apiSmokeTestMinecraftVersion =
(findProperty("apiSmokeTestMinecraftVersion") as String?) ?: "1.19.2"
fun parseMinecraftVersion(version: String): Triple<Int, Int, Int> {
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
if (requiresModernJava(version)) return integrationTestJavaVersionModern
val (_, minor, _) = parseMinecraftVersion(version)
if (minor <= 16) return integrationTestJavaVersionLegacy16
return integrationTestJavaVersionLegacy
}
fun supportsDamageTypeIntegrationDatapack(version: String): Boolean {
val (major, minor, patch) = parseMinecraftVersion(version)
if (major > 1) return true
if (major < 1) return false
if (minor > 21) return true
return minor == 21 && patch >= 11
}
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<String>,
val failureDetails: List<String>
)
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<String>()
val failureDetails = mutableListOf<String>()
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>("runServer") {
enabled = false
description = "Disabled. Use integrationTest/integrationTestMatrix instead."
}
tasks.withType<RunServer>().configureEach {
notCompatibleWithConfigurationCache("run-paper tasks access Project at execution time.")
}
val integrationTestMatrixTasks = mutableListOf<TaskProvider<Task>>()
var previousMatrixTask: TaskProvider<Task>? = 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 datapackSourceDir = layout.projectDirectory.dir("src/integrationTest/datapacks")
val writePropsTask =
tasks.register<WriteProperties>("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<Map<*, *>>
?: 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>("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 damageTypeDatapackDir = runDir.resolve("world/datapacks/ocmtest-bypasses-cooldown")
if (supportsDamageTypeIntegrationDatapack(version)) {
copy {
from(datapackSourceDir)
into(runDir.resolve("world/datapacks"))
}
} else if (damageTypeDatapackDir.exists()) {
delete(damageTypeDatapackDir)
}
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<String>()
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 apiSmokeTestSuffix = versionTaskSuffix(apiSmokeTestMinecraftVersion)
val apiSmokeRunDir = file("run/api-smoke-$apiSmokeTestMinecraftVersion")
val apiSmokeResultFile = apiSmokeRunDir.resolve("plugins/OldCombatMechanicsApiSmokeTest/test-results.txt")
val apiSmokeWritePropertiesTask =
tasks.register<WriteProperties>("writeApiSmokeProperties$apiSmokeTestSuffix") {
encoding = "UTF-8"
property("online-mode", false)
destinationFile.set(apiSmokeRunDir.resolve("server.properties"))
}
val runApiSmokeServerTask =
tasks.register<RunServer>("runApiSmokeServer$apiSmokeTestSuffix") {
dependsOn(apiSmokeWritePropertiesTask, shadowJarTask, apiSmokeTestJarTask)
runDirectory.set(apiSmokeRunDir)
minecraftVersion(apiSmokeTestMinecraftVersion)
jvmArgs("-Dcom.mojang.eula.agree=true")
javaLauncher.set(
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(requiredJavaVersion(apiSmokeTestMinecraftVersion)))
}
)
pluginJars.from(shadowJarTask.flatMap { it.archiveFile })
pluginJars.from(apiSmokeTestJarTask.flatMap { it.archiveFile })
doFirst {
if (apiSmokeResultFile.exists()) {
apiSmokeResultFile.delete()
}
val ocmConfigFile = apiSmokeRunDir.resolve("plugins/OldCombatMechanics/config.yml")
if (ocmConfigFile.exists()) {
ocmConfigFile.delete()
}
val pluginNames = pluginJars.files.map { it.name }
if (pluginNames.any { it.contains("tests", ignoreCase = true) }) {
throw GradleException("apiSmokeTest must not load the integration-test plugin jar: $pluginNames")
}
logger.lifecycle("API smoke server plugins: ${pluginNames.joinToString(", ")}")
}
}
val checkApiSmokeResultsTask =
tasks.register("checkApiSmokeResults$apiSmokeTestSuffix") {
doLast {
if (!apiSmokeResultFile.exists()) {
throw GradleException(
"API smoke result file not found for $apiSmokeTestMinecraftVersion: ${apiSmokeResultFile.relativeTo(
projectDir
)}"
)
}
val result = apiSmokeResultFile.readText().trim()
logger.lifecycle("API smoke result for $apiSmokeTestMinecraftVersion: $result")
if (result != "PASS") {
throw GradleException("API smoke test failed for $apiSmokeTestMinecraftVersion: $result")
}
}
}
tasks.register("apiSmokeTest") {
group = "verification"
description = "Runs the Java API smoke plugin against a live Paper server ($apiSmokeTestMinecraftVersion)."
dependsOn(runApiSmokeServerTask)
finalizedBy(checkApiSmokeResultsTask)
}
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)
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
# 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=26.1.2, 26.1.1, 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
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@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, and ensure extensions are enabled
setlocal EnableExtensions
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
"%COMSPEC%" /c exit 1
: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
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>me.vagdedes</groupId>
<artifactId>spartan-api</artifactId>
<version>190</version>
<description>POM was created from install:install-file</description>
</project>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>me.vagdedes</groupId>
<artifactId>spartan-api</artifactId>
<versioning>
<release>190</release>
<versions>
<version>190</version>
</versions>
<lastUpdated>20180610163102</lastUpdated>
</versioning>
</metadata>
-120
View File
@@ -1,120 +0,0 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>kernitus.plugin.OldCombatMechanics</groupId>
<artifactId>OldCombatMechanics</artifactId>
<version>1.7.4</version>
<name>OldCombatMechanics</name>
<description>Implements pre-1.9 combat-related mechanics</description>
<url>https://github.com/kernitus/BukkitOldCombatMechanics</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<repositories>
<repository>
<id>bukkit</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>bstats-repo</id>
<url>http://repo.bstats.org/content/repositories/releases/</url>
</repository>
<repository>
<id>placeholderapi-repo</id>
<url>http://repo.extendedclip.com/content/repositories/placeholderapi/</url>
</repository>
<repository>
<id>local-file-repo</id>
<url>file://${basedir}/internal-file-repo</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.bukkit</groupId>
<artifactId>bukkit</artifactId>
<version>1.14.4-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.bstats</groupId>
<artifactId>bstats-bukkit</artifactId>
<version>1.2</version>
</dependency>
<!--Shaded in by bukkit -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.21.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>2.8.6</version>
<scope>provided</scope>
</dependency>
<!--SPARTAN API-->
<dependency>
<groupId>me.vagdedes</groupId>
<artifactId>spartan-api</artifactId>
<version>190</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<defaultGoal>clean package</defaultGoal>
<finalName>${project.artifactId}</finalName>
<sourceDirectory>src/main/java</sourceDirectory>
<resources>
<resource>
<targetPath>.</targetPath>
<filtering>true</filtering>
<directory>${basedir}/src/main/resources</directory>
<includes>
<include>**/*</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.bstats:*</include>
</includes>
</artifactSet>
<relocations>
<relocation>
<pattern>org.bstats</pattern>
<shadedPattern>kernitus.plugin.OldCombatMechanics.lib.bstats</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/run-integration-matrix.sh [-v versions] [-s spec-filter] [-t test-filter] [--] [extra Gradle args...]
Wraps ./gradlew integrationTest using repository-relative paths.
Options:
-v, --versions LIST Comma-separated Minecraft versions, for example 1.19.2,1.21.11
-s, --spec-filter TEXT Kotest spec filter passed as -Dkotest.filter.specs=TEXT
-t, --test-filter TEXT Kotest test filter passed as -Dkotest.filter.tests=TEXT
-h, --help Show this help text
This command may start Paper servers. For a non-invasive check, use
scripts/validate-module-assignments.sh or scripts/summarise-integration-results.sh.
USAGE
}
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
versions=
spec_filter=
test_filter=
while [ "$#" -gt 0 ]; do
case "$1" in
-v|--versions)
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
versions=$2
shift 2
;;
-s|--spec-filter)
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
spec_filter=$2
shift 2
;;
-t|--test-filter)
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
test_filter=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
printf '%s\n' "Unknown option: $1" >&2
usage >&2
exit 2
;;
*)
break
;;
esac
done
set -- integrationTest "$@"
[ -z "$versions" ] || set -- "$@" "-PintegrationTestVersions=$versions"
[ -z "$spec_filter" ] || set -- "$@" "-Dkotest.filter.specs=$spec_filter"
[ -z "$test_filter" ] || set -- "$@" "-Dkotest.filter.tests=$test_filter"
printf '%s\n' "Repository: $repo_root"
printf '%s\n' "Running: ./gradlew $*"
cd "$repo_root"
exec ./gradlew "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/suggest-integration-matrix.sh [-h] [changed-path ...]
Suggests a small integration-test matrix from changed paths. With no paths, it
tries git diff --name-only. Suggestions are conservative hints, not a quality
gate.
USAGE
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
if [ "$#" -eq 0 ]; then
if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
set -- $(git -C "$repo_root" diff --name-only -- src src/integrationTest build.gradle.kts gradle.properties || true)
fi
fi
versions="1.19.2,1.21.11"
reason="default modern coverage for uncertain changes"
filters=""
for path in "$@"; do
case "$path" in
src/main/resources/config.yml|*config*|*modeset*|*Module*)
versions="1.12,1.19.2,1.21.11"
reason="config or module assignment changes benefit from legacy and modern coverage"
filters="-Dkotest.filter.specs=*Module*,*Config*,*Modeset*"
;;
*Packet*|*packet*|*FakePlayer*|*fake-player*)
versions="1.19.2,1.21.11"
reason="packet or fake-player areas need modern Paper/PacketEvents coverage"
;;
*reflection*|*nms*|*legacy*|*1_12*)
versions="1.12,1.19.2"
reason="legacy or reflective code should include the Java 8-era path"
;;
esac
done
printf 'Recommended versions: %s\n' "$versions"
printf 'Reason: %s\n' "$reason"
[ -z "$filters" ] || printf 'Suggested filter: %s\n' "$filters"
printf 'Run with: scripts/run-integration-matrix.sh -v %s' "$versions"
[ -z "$filters" ] || printf ' -- %s' "$filters"
printf '\n'
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/summarise-integration-results.sh [-h]
Reports compact integration-test outcomes from run/* plugin result files.
It deliberately does not open build/integration-test-logs/*.log. If compact
results are insufficient, ask explicitly before inspecting full server logs.
USAGE
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
'') ;;
*)
printf '%s\n' "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
found=0
failed=0
for run_dir in "$repo_root"/run/*; do
[ -d "$run_dir" ] || continue
version=${run_dir##*/}
result_file=$run_dir/plugins/OldCombatMechanicsTest/test-results.txt
failures_file=$run_dir/plugins/OldCombatMechanicsTest/test-failures.txt
if [ ! -f "$result_file" ] && [ ! -f "$failures_file" ]; then
continue
fi
found=1
result="MISSING"
[ ! -f "$result_file" ] || result=$(tr -d '\r' < "$result_file" | sed '/^[[:space:]]*$/d' | sed -n '1p')
printf '%s: %s\n' "$version" "$result"
if [ "$result" != "PASS" ]; then
failed=1
fi
if [ -s "$failures_file" ]; then
sed 's/^/ /' "$failures_file"
failed=1
fi
done
if [ "$found" -eq 0 ]; then
printf '%s\n' 'No compact integration-test result files found under run/*/plugins/OldCombatMechanicsTest/.' >&2
exit 1
fi
exit "$failed"
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/validate-module-assignments.sh [-h] [config-file]
Best-effort static validation for bundled module assignment lists.
Checks that every listed module is assigned to only one configurable top-level
category: always_enabled_modules, disabled_modules, or the aggregate modesets
category. It also checks duplicate entries within the same exact list or
modeset, and that internal modules are not listed in those configurable
sections. Reusing a module in more than one modeset is allowed because modesets
are alternative player modes.
The default config file is src/main/resources/config.yml, resolved relative to
the repository root. This script does not start servers.
USAGE
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
config_file=${1:-"$repo_root/src/main/resources/config.yml"}
python3 - "$config_file" <<'PY'
import re
import sys
from collections import defaultdict
from pathlib import Path
config = Path(sys.argv[1])
if not config.is_file():
print(f"Config file not found: {config}", file=sys.stderr)
sys.exit(2)
internal = {"modeset-listener", "attack-cooldown-tracker", "entity-damage-listener"}
assignments = defaultdict(list)
section = None
modeset = None
top_level = re.compile(r"^([A-Za-z0-9_.-]+):\s*(?:#.*)?$")
mode_name = re.compile(r"^ ([A-Za-z0-9_.-]+):\s*(?:#.*)?$")
list_item = re.compile(r"^\s*-\s*['\"]?([^'\"#\s]+)['\"]?\s*(?:#.*)?$")
for number, raw in enumerate(config.read_text(encoding="utf-8").splitlines(), 1):
top = top_level.match(raw)
if top:
section = top.group(1)
modeset = None
continue
if section == "modesets":
mode = mode_name.match(raw)
if mode:
modeset = mode.group(1)
continue
item = list_item.match(raw)
if not item:
continue
module = item.group(1)
if section == "always_enabled_modules":
category = "always_enabled_modules"
bucket = "always_enabled_modules"
elif section == "disabled_modules":
category = "disabled_modules"
bucket = "disabled_modules"
elif section == "modesets" and modeset:
category = "modesets"
bucket = f"modesets.{modeset}"
else:
continue
assignments[module].append((category, bucket, number))
errors = []
for module, locations in sorted(assignments.items()):
if module in internal:
for _category, bucket, number in locations:
errors.append(f"internal module listed: {module} in {bucket} at line {number}")
exact_locations = defaultdict(list)
category_locations = defaultdict(list)
for category, bucket, number in locations:
exact_locations[bucket].append(number)
category_locations[category].append((bucket, number))
if len(category_locations) > 1:
where = ", ".join(
f"{bucket}:line {number}"
for _category, bucket, number in locations
)
errors.append(f"conflicting module assignment: {module} in {where}")
for bucket, numbers in sorted(exact_locations.items()):
if len(numbers) > 1:
where = ", ".join(f"line {number}" for number in numbers)
errors.append(f"duplicate module assignment within {bucket}: {module} at {where}")
if errors:
print("Module assignment validation FAILED:")
for error in errors:
print(f"- {error}")
sys.exit(1)
print(f"Module assignment validation passed for {config}")
print(f"Checked {len(assignments)} assigned module entr{'y' if len(assignments) == 1 else 'ies'}.")
PY
+6
View File
@@ -0,0 +1,6 @@
/*
* 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"
@@ -0,0 +1,103 @@
/*
* 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.apiSmokeTest;
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent;
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride;
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Set;
public final class ApiSmokeTestPlugin extends JavaPlugin {
private static final String KNOWN_CONFIGURABLE_MODULE = "sword-blocking";
@Override
public void onEnable() {
try {
RegisteredServiceProvider<OldCombatMechanicsAPI> registration =
Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI.class);
if (registration == null) {
throw new IllegalStateException("OldCombatMechanicsAPI service is not registered");
}
OldCombatMechanicsAPI api = registration.getProvider();
if (!api.getModuleNames().contains(KNOWN_CONFIGURABLE_MODULE)) {
throw new IllegalStateException("OldCombatMechanicsAPI is missing known configurable module "
+ KNOWN_CONFIGURABLE_MODULE);
}
Set<String> modesetNames = api.getModesetNames();
if (modesetNames.isEmpty()) {
throw new IllegalStateException("OldCombatMechanicsAPI did not expose any modesets");
}
World world = Bukkit.getWorlds().get(0);
Set<String> allowedModesets = api.getAllowedModesets(world);
if (allowedModesets.isEmpty()) {
throw new IllegalStateException("OldCombatMechanicsAPI did not expose allowed modesets for "
+ world.getName());
}
writeResult("PASS");
} catch (Throwable throwable) {
getLogger().severe("API smoke test failed: " + throwable.getMessage());
try {
writeResult("FAIL");
} catch (IOException ioException) {
getLogger().severe("Could not write API smoke test result: " + ioException.getMessage());
}
} finally {
Bukkit.getScheduler().runTask(this, Bukkit::shutdown);
}
}
private void writeResult(String result) throws IOException {
File resultFile = new File(getDataFolder(), "test-results.txt");
File parent = resultFile.getParentFile();
if (parent != null) {
parent.mkdirs();
}
Files.write(resultFile.toPath(), result.getBytes(StandardCharsets.UTF_8));
}
@SuppressWarnings("unused")
private void verifyPlayerSpecificApiCompiles(OldCombatMechanicsAPI api, Player player) {
api.forceEnableModuleForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
api.isModuleEnabledForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
api.clearModuleOverrideForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
api.setModuleOverridesForPlayer(player, java.util.Collections.singletonMap(
KNOWN_CONFIGURABLE_MODULE,
PlayerModuleOverride.FORCE_ENABLED
));
String currentModeset = api.getModesetForPlayer(player);
if (currentModeset != null) {
api.setModesetForPlayer(player, currentModeset);
}
PlayerModesetChangeEvent modesetEvent = new PlayerModesetChangeEvent(
player,
player.getWorld(),
currentModeset,
currentModeset,
PlayerModesetChangeEvent.Reason.API
);
modesetEvent.getReason();
PlayerModuleOverrideChangeEvent overrideEvent = new PlayerModuleOverrideChangeEvent(
player,
KNOWN_CONFIGURABLE_MODULE,
PlayerModuleOverride.DEFAULT,
PlayerModuleOverride.FORCE_ENABLED
);
overrideEvent.getNewOverride();
}
}
+4
View File
@@ -0,0 +1,4 @@
main: kernitus.plugin.OldCombatMechanics.apiSmokeTest.ApiSmokeTestPlugin
name: OldCombatMechanicsApiSmokeTest
version: 1.0.0
depend: [OldCombatMechanics]
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"ocmtest:tick_damage"
]
}
@@ -0,0 +1,5 @@
{
"message_id": "ocmtest.tick_damage",
"scaling": "never",
"exhaustion": 0.0
}
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 81,
"description": "OCM bypasses cooldown integration-test datapack"
}
}
@@ -0,0 +1,24 @@
/*
* 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.api.OldCombatMechanicsAPI;
import java.util.HashMap;
import java.util.Map;
import org.bukkit.entity.Player;
final class PlayerModuleOverrideJavaInterop {
private PlayerModuleOverrideJavaInterop() {
}
@SuppressWarnings({"rawtypes", "unchecked"})
static void setOverridesWithNullValue(OldCombatMechanicsAPI api, Player player, String moduleName) {
Map overrides = new HashMap();
overrides.put(moduleName, null);
api.setModuleOverridesForPlayer(player, overrides);
}
}
@@ -0,0 +1,184 @@
/*
* 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 kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
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 = apiAttack != null && CompatibilityCapabilities.isPlayerAttackApiAvailable(attacker.javaClass)
if (useApiAttack) {
val attackMethod = apiAttack
val beforeApiAttack = captureLivingAttackSignal(target)
try {
val apiResult = attackMethod.invoke(attacker, target)
if (attackMethod.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 when the Bukkit API attack path is absent or unobservable.
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<String>(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.
}
}
// Last-resort fallback: try Bukkit API even when the capability probe was inconclusive.
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<Class<*>, List<Method>>()
private fun resolveNmsAttackMethods(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {
return attackMethodCache.computeIfAbsent(attackerHandleClass) {
buildAttackMethodCandidates(attackerHandleClass, targetHandleClass)
}
}
private fun buildAttackMethodCandidates(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {
// 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<Method> {
val methods = LinkedHashMap<String, Method>()
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
}
@@ -0,0 +1,477 @@
/*
* 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<ModuleAttackCooldown>().firstOrNull()
extensions(MainThreadDispatcherExtension(testPlugin))
fun <T> 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<String, Double>,
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<String, Any?>()
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)
var currentPlayer = spawned.player
runSync {
spawned.fake.teleport(Location(otherWorld, 0.0, 100.0, 0.0, 0f, 0f)) shouldBe true
currentPlayer = spawned.fake.requireBukkitPlayer()
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(currentPlayer, world))
}
runSync { currentAttackSpeed(currentPlayer) } shouldBe (4.0 plusOrMinus 0.01)
runSync {
spawned.fake.teleport(Location(world, 0.0, 100.0, 0.0, 0f, 0f)) shouldBe true
currentPlayer = spawned.fake.requireBukkitPlayer()
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(currentPlayer, otherWorld))
}
runSync { currentAttackSpeed(currentPlayer) } 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)
}
}
}
})
@@ -0,0 +1,74 @@
/*
* 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 <T> 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
}
}
})
@@ -0,0 +1,341 @@
/*
* 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 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<ModuleAttackRange>().firstOrNull()
val attackRangeApiAvailable = ModuleAttackRange.isAttackRangeApiAvailable()
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<String> {
val f = kernitus.plugin.OldCombatMechanics.utilities.Config::class.java.getDeclaredField(field)
f.isAccessible = true
@Suppress("UNCHECKED_CAST")
return f.get(null) as MutableSet<String>
}
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 (AttackRange API available)") {
if (!attackRangeApiAvailable) 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 (AttackRange API available)") {
if (!attackRangeApiAvailable) 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 (AttackRange API available)") {
if (!attackRangeApiAvailable) 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 (AttackRange API available)") {
if (!attackRangeApiAvailable) 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)
}
}
})
@@ -0,0 +1,101 @@
/*
* 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<Attribute, AttributeModifier>()
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<AttributeModifier> {
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<Attribute, AttributeModifier>
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()
}
@@ -0,0 +1,164 @@
/*
* 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 kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
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<ModuleChorusFruit>()
.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 feetPassable = CompatibilityCapabilities.isBlockPassable(feet)
val headPassable = CompatibilityCapabilities.isBlockPassable(head)
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()
}
}
}
})
@@ -0,0 +1,152 @@
/*
* 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",
linkedMapOf(
"custom" to listOf("disable-offhand"),
"alt" to listOf("old-golden-apples")
)
)
oldConfig.set("worlds.__default__", null)
oldConfig.set("worlds.world", listOf("custom", "alt"))
oldConfig.set("worlds.nether", listOf("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")
upgradedConfig.getStringList("worlds.nether") shouldBe listOf("alt")
upgradedConfig.getStringList("worlds.__default__") shouldBe listOf("custom", "alt")
}
}
}
test("config upgrade preserves existing default world 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",
linkedMapOf(
"custom" to listOf("disable-offhand"),
"alt" to listOf("old-golden-apples")
)
)
oldConfig.set("worlds.__default__", listOf("legacy-default"))
oldConfig.set("worlds.world", listOf("custom"))
oldConfig.save(configFile)
Config.reload()
val upgradedConfig = ocm.config
upgradedConfig.getInt("config-version") shouldBe currentVersion
upgradedConfig.getStringList("worlds.__default__") shouldBe listOf("legacy-default")
upgradedConfig.getStringList("worlds.world") shouldBe listOf("custom")
}
}
}
})
@@ -0,0 +1,46 @@
/*
* 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)")
}
}
}
}
})
@@ -0,0 +1,212 @@
/*
* 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.CompatibilityCapabilities
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
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<ModuleOldToolDamage>()
.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<String, Any?>()
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") {
if (!CompatibilityCapabilities.isMaterialAvailable("TRIDENT")) return@test
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<Double?>()
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") {
if (!CompatibilityCapabilities.isMaterialAvailable("TRIDENT") ||
!CompatibilityCapabilities.isBukkitClassAvailable("org.bukkit.entity.Trident")) return@test
val tridentMat = Material.matchMaterial("TRIDENT") ?: 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<Trident>()
runSync {
tridentRef.set(
world.spawn(world.spawnLocation, Trident::class.java).apply {
this.item = ItemStack(tridentMat)
}
)
}
val damageCapture = AtomicReference<Double?>()
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") {
if (!CompatibilityCapabilities.isMaterialAvailable("MACE")) return@test
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<Double?>()
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)
}
}
})
@@ -0,0 +1,50 @@
/*
* 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.XEnchantment
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils
import org.bukkit.Material
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
class DefenceUtilsIntegrationTest :
FunSpec({
val enchantmentReductionMethod =
DefenceUtils::class.java.getDeclaredMethod(
"calculateArmourEnchantmentReductionFactor",
Array<ItemStack>::class.java,
EntityDamageEvent.DamageCause::class.java,
Boolean::class.javaPrimitiveType
).also { it.isAccessible = true }
fun protectionArmour(): Array<ItemStack> {
val protection = checkNotNull(XEnchantment.PROTECTION.get())
return arrayOf(
ItemStack(Material.DIAMOND_BOOTS),
ItemStack(Material.DIAMOND_LEGGINGS),
ItemStack(Material.DIAMOND_CHESTPLATE),
ItemStack(Material.DIAMOND_HELMET)
).also { armour ->
armour.forEach { it.addUnsafeEnchantment(protection, 4) }
}
}
fun enchantmentReduction(cause: EntityDamageEvent.DamageCause): Double = enchantmentReductionMethod.invoke(
null,
protectionArmour(),
cause,
false
) as Double
test("protection applies to burn ticks like vanilla 1.8") {
enchantmentReduction(EntityDamageEvent.DamageCause.FIRE_TICK) shouldBe (0.8 plusOrMinus 0.0001)
}
})
@@ -0,0 +1,86 @@
/*
* 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<ModuleDisableOffHand>()
.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
}
}
})
@@ -0,0 +1,108 @@
/*
* 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 <T> 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<InventoryView>(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<Inventory>(bottomMethod, view)
?: error("bottom inventory missing")
val top =
Reflector.invokeMethod<Inventory>(topMethod, view)
?: error("top inventory missing")
bottom.type shouldBe InventoryType.PLAYER
} finally {
runSync { player.closeInventory() }
}
}
})
@@ -0,0 +1,178 @@
/*
* 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<ModuleDisableEnderpearlCooldown>().firstOrNull()
?: error("ModuleDisableEnderpearlCooldown not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun <T> 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
}
}
})
@@ -0,0 +1,876 @@
/*
* 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<Any>(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<Any>(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")
}
fun teleport(location: Location): Boolean {
if (isLegacy9) {
val teleported = legacyImpl9!!.teleport(location)
if (teleported) {
serverPlayer = legacyImpl9.entityPlayer ?: serverPlayer
bukkitPlayer = legacyImpl9.bukkitPlayer ?: Bukkit.getPlayer(uuid)
}
return teleported
}
val teleported = bukkitPlayer?.teleport(location) ?: false
if (teleported) {
bukkitPlayer = Bukkit.getPlayer(uuid) ?: bukkitPlayer
}
return teleported
}
fun requireBukkitPlayer(): Player = Bukkit.getPlayer(uuid) ?: bukkitPlayer
?: error("Bukkit player with UUID $uuid not found!")
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<Any>(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<Any?>()
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<GameProfile>(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<Any?>()
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<Any>(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<Any>
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<UUID, Any>
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<Any>(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<Any>
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<UUID, Any>
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<Any?>(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<Any>(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)
}
}
@@ -0,0 +1,319 @@
/*
* 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<ModuleFishingRodVelocity>()
.firstOrNull() ?: error("ModuleFishingRodVelocity not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun <T> 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<Any>(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<out Enum<*>>, "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<Class<*>>(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()
}
}
}
})
@@ -0,0 +1,382 @@
/*
* 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()
}
}
}
})
@@ -0,0 +1,420 @@
/*
* 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<OCMTest> = 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<ItemStack>, 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<ItemStack>, 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<ItemStack>, 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<ItemStack>, 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<ItemStack>): 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
}
}
}
@@ -0,0 +1,182 @@
/*
* 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 <T> 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() }
}
}
})
@@ -0,0 +1,754 @@
/*
* 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.NamespacedKey
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<FakePlayer, Player> {
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")
}
fun supportsDamageTypeApi(): Boolean {
return try {
Class.forName("org.bukkit.damage.DamageType")
Class.forName("org.bukkit.damage.DamageSource")
Class.forName("org.bukkit.Registry").getField("DAMAGE_TYPE")
EntityDamageEvent::class.java.getMethod("getDamageSource")
true
} catch (_: ReflectiveOperationException) {
false
} catch (_: LinkageError) {
false
}
}
fun damageTypeClass(): Class<*> = Class.forName("org.bukkit.damage.DamageType")
fun resolveDamageType(key: NamespacedKey): Any {
val registry = Class.forName("org.bukkit.Registry").getField("DAMAGE_TYPE").get(null)
return registry.javaClass.getMethod("get", NamespacedKey::class.java).invoke(registry, key)
?: error("Damage type $key was not registered")
}
fun bypassesCooldownTagContains(damageType: Any): Boolean {
val getTag = Bukkit::class.java.getMethod(
"getTag",
String::class.java,
NamespacedKey::class.java,
Class::class.java
)
val tag = listOf("damage_type", "damage_types").firstNotNullOfOrNull { registry ->
try {
getTag.invoke(null, registry, NamespacedKey.minecraft("bypasses_cooldown"), damageTypeClass())
} catch (_: ReflectiveOperationException) {
null
} catch (_: IllegalArgumentException) {
null
}
} ?: error("minecraft:bypasses_cooldown damage-type tag was not available")
val values = (tag as org.bukkit.Tag<*>).values
return values.contains(damageType)
}
fun buildDamageSource(damageType: Any): Any {
val sourceClass = Class.forName("org.bukkit.damage.DamageSource")
val builder = sourceClass.getMethod("builder", damageTypeClass()).invoke(null, damageType)
return builder.javaClass.getMethod("build").invoke(builder)
}
fun createDamageEvent(victim: LivingEntity, damageSource: Any, damage: Double): EntityDamageEvent {
return EntityDamageEvent::class.java.getConstructor(
org.bukkit.entity.Entity::class.java,
EntityDamageEvent.DamageCause::class.java,
Class.forName("org.bukkit.damage.DamageSource"),
java.lang.Double.TYPE
).newInstance(
victim,
EntityDamageEvent.DamageCause.CUSTOM,
damageSource,
damage
) as EntityDamageEvent
}
fun configuredDamageListener(): Pair<Listener, java.lang.reflect.Field> {
val listener = HandlerList.getRegisteredListeners(ocm)
.map { it.listener }
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
?: error("EntityDamageByEntityListener not registered")
val field = listener.javaClass.getDeclaredField("lastDamages").apply { isAccessible = true }
return listener to field
}
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<EntityDamageByEntityEvent>()
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("custom bypasses_cooldown damage type skips OCM invulnerability overdamage") {
if (!supportsDamageTypeApi()) {
plugin.logger.info("Skipping bypasses_cooldown damage-type test: Bukkit damage-source API is unavailable")
return@test
}
val tickDamageType = resolveDamageType(NamespacedKey("ocmtest", "tick_damage"))
bypassesCooldownTagContains(tickDamageType).shouldBe(true)
val genericDamageType = resolveDamageType(NamespacedKey.minecraft("generic"))
bypassesCooldownTagContains(genericDamageType).shouldBe(false)
var taggedVictim: LivingEntity? = null
var controlVictim: LivingEntity? = null
try {
runSync {
val (edbelListener, lastDamagesField) = configuredDamageListener()
@Suppress("UNCHECKED_CAST")
val lastDamages = lastDamagesField.get(edbelListener) as MutableMap<UUID, Double>
val world = checkNotNull(Bukkit.getWorld("world"))
taggedVictim = spawnVictim(Location(world, 1.2, 100.0, 0.0)).apply {
noDamageTicks = maximumNoDamageTicks
lastDamage = 4.0
}
controlVictim = spawnVictim(Location(world, 3.2, 100.0, 0.0)).apply {
noDamageTicks = maximumNoDamageTicks
lastDamage = 4.0
}
val currentTaggedVictim = checkNotNull(taggedVictim)
val currentControlVictim = checkNotNull(controlVictim)
lastDamages[currentTaggedVictim.uniqueId] = 4.0
lastDamages[currentControlVictim.uniqueId] = 4.0
val taggedEvent = createDamageEvent(currentTaggedVictim, buildDamageSource(tickDamageType), 4.0)
Bukkit.getPluginManager().callEvent(taggedEvent)
taggedEvent.isCancelled.shouldBe(false)
taggedEvent.damage.shouldBe(4.0)
val controlEvent = createDamageEvent(currentControlVictim, buildDamageSource(genericDamageType), 4.0)
Bukkit.getPluginManager().callEvent(controlEvent)
controlEvent.isCancelled.shouldBe(true)
controlEvent.damage.shouldBe(0.0)
}
} finally {
runSync {
taggedVictim?.remove()
controlVictim?.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<EntityDamageByEntityEvent>()
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<EntityDamageByEntityEvent>()
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<EntityDamageByEntityEvent>()
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<UUID, Double>
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<UUID, Double>
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<UUID, Double>
lastDamages?.remove(victim?.uniqueId)
victim?.remove()
}
}
}
})
@@ -0,0 +1,186 @@
/*
* 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/.
*/
@file:Suppress("ktlint:standard:package-name")
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<String>(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<Throwable>) {
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,
PlayerModuleOverrideApiIntegrationTest::class,
DisableOffhandIntegrationTest::class,
DisableOffhandReflectionIntegrationTest::class,
InGameTesterIntegrationTest::class,
CopperToolsIntegrationTest::class,
OldPotionEffectsIntegrationTest::class,
InvulnerabilityDamageIntegrationTest::class,
MobInvulnerabilityIntegrationTest::class,
FireAspectOverdamageIntegrationTest::class,
OldCriticalHitsIntegrationTest::class,
OldToolDamageMobIntegrationTest::class,
WeaponDurabilityIntegrationTest::class,
GoldenAppleIntegrationTest::class,
OldArmourDurabilityIntegrationTest::class,
OldArmourStrengthModesetIntegrationTest::class,
MixedModePvPIntegrationTest::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)
}
}
)
}
}
@@ -0,0 +1,451 @@
/*
* 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<Any>
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<Any, Any>
byUuid?.put(uuid, entityPlayer)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
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<Any>
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<Any, Any>
byUuid?.remove(uuid)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
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<out Any?>,
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<out Enum<*>>
return java.lang.Enum.valueOf(enumType, name)
}
private fun enumValue(enumClass: Class<*>, gameMode: GameMode): Any {
return enumValue(enumClass, gameMode.name)
}
}
@@ -0,0 +1,437 @@
/*
* 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<Any>
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<Any>()
// 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<Any> }.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 teleport(location: Location): Boolean {
val ep = entityPlayer ?: return false
val bp = bukkitPlayer ?: return false
val targetWorld = location.world ?: return false
if (bp.world.uid == targetWorld.uid) {
return bp.teleport(location)
}
val craftWorld = craft("CraftWorld").cast(targetWorld)
val targetWorldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
val dimension = targetWorldServer.javaClass.getField("dimension").getInt(targetWorldServer)
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
val moveMethod =
playerList.javaClass.methods.firstOrNull {
it.name == "moveToWorld" &&
it.parameterTypes.size == 5 &&
it.parameterTypes[0].isAssignableFrom(ep.javaClass) &&
it.parameterTypes[1] == Int::class.javaPrimitiveType &&
it.parameterTypes[2] == Boolean::class.javaPrimitiveType &&
it.parameterTypes[3].isAssignableFrom(Location::class.java) &&
it.parameterTypes[4] == Boolean::class.javaPrimitiveType
} ?: return false
val moved = moveMethod.invoke(playerList, ep, dimension, false, location, true) ?: return false
entityPlayer = moved
bukkitPlayer = moved.javaClass.getMethod("getBukkitEntity").invoke(moved) as Player
updateCraftMaps(craftServer, bukkitPlayer!!)
return bukkitPlayer!!.world.uid == targetWorld.uid
}
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<String, Player>
map[player.name.lowercase(Locale.getDefault())] = player
}
runCatching {
val uuidField = craftServer.javaClass.getDeclaredField("playersByUUID")
uuidField.isAccessible = true
val map = uuidField.get(craftServer) as MutableMap<UUID, Player>
map[player.uniqueId] = player
}
}
}
@@ -0,0 +1,614 @@
/*
* 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.collections.shouldNotBeEmpty
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Difficulty
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.AbstractArrow
import org.bukkit.entity.Player
import org.bukkit.entity.Skeleton
import org.bukkit.entity.Trident
import org.bukkit.entity.Zombie
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 org.bukkit.scheduler.BukkitRunnable
import java.util.concurrent.Callable
// Instruments mob->player melee cadence: a forced swing every tick must never damage the
// player more often than the vanilla half-rule window (maximumNoDamageTicks / 2) allows.
@OptIn(ExperimentalKotest::class)
class MobInvulnerabilityIntegrationTest :
FunSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extensions(MainThreadDispatcherExtension(plugin))
fun <T> runSync(action: () -> T): T = if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(plugin, Callable { action() }).get()
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
data class HitRecord(
val tick: Int,
val damage: Double,
val finalDamage: Double,
val cancelled: Boolean,
val noDamageTicks: Int
)
suspend fun measureHitCadence(label: String, armed: Boolean): Pair<Int, List<HitRecord>> {
val records = mutableListOf<HitRecord>()
lateinit var victimFake: FakePlayer
lateinit var victim: Player
lateinit var zombie: Zombie
var tick = 0
// Long enough for slow-accumulator anticheat heuristics (e.g. NCP godmode) to trigger
val totalTicks = 200
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 != zombie.uniqueId) return
records.add(
HitRecord(tick, event.damage, event.finalDamage, event.isCancelled, victim.noDamageTicks)
)
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
// Offset away from the origin used by the other combat specs
val base = Location(world, 20.0, 100.0, 20.0)
victimFake = FakePlayer(plugin)
victimFake.spawn(base)
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
victim.gameMode = GameMode.SURVIVAL
victim.isInvulnerable = false
victim.inventory.clear()
victim.inventory.armorContents = arrayOfNulls(4)
victim.health = victim.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
zombie = world.spawn(base.clone().add(1.1, 0.0, 0.0), Zombie::class.java)
zombie.setAI(false)
zombie.isSilent = true
zombie.equipment?.setItemInMainHand(
if (armed) ItemStack(Material.IRON_SWORD) else ItemStack(Material.AIR)
)
Bukkit.getPluginManager().registerEvents(listener, plugin)
}
val maxTicks = runSync { victim.maximumNoDamageTicks }
plugin.logger.info("MobInvuln[$label]: victim maximumNoDamageTicks=$maxTicks")
runSync {
object : BukkitRunnable() {
override fun run() {
tick += 1
if (tick > totalTicks) {
cancel()
return
}
// Top the victim up between swings; healing does not touch invulnerability state
if (victim.health < 10.0) victim.health = victim.maxHealth
zombie.fireTicks = 0
// Bukkit damage() drives the same NMS hurt() pipeline (invulnerability gate
// included) without depending on obfuscated mob attack method names
victim.damage(3.0, zombie)
}
}.runTaskTimer(plugin, 1L, 1L)
}
while (tick <= totalTicks) delayTicks(5)
delayTicks(2)
records.forEach {
plugin.logger.info(
"MobInvuln[$label]: tick=${it.tick} dmg=${it.damage} final=${it.finalDamage}" +
" cancelled=${it.cancelled} ndt=${it.noDamageTicks}"
)
}
return maxTicks to records
} finally {
HandlerList.unregisterAll(listener)
runSync {
victimFake.removePlayer()
zombie.remove()
}
}
}
fun landedTicks(records: List<HitRecord>): List<Int> =
records.filter { !it.cancelled && it.finalDamage > 0.0 }.map { it.tick }
suspend fun assertVanillaCadence(label: String, armed: Boolean) {
val (maxTicks, records) = measureHitCadence(label, armed)
val landed = landedTicks(records)
plugin.logger.info(
"MobInvuln[$label]: events=${records.size} landedTicks=$landed window=${maxTicks / 2}"
)
landed.shouldNotBeEmpty()
// A swing lands every maxTicks/2 at the earliest; anything closer is damage leaking
// through the invulnerability window
landed.zipWithNext { a, b -> b - a }.forEach { gap ->
gap.shouldBeGreaterThanOrEqual(maxTicks / 2)
}
}
test("unarmed zombie swinging every tick damages a player at most every half-window") {
assertVanillaCadence("unarmed", armed = false)
}
test("sword-wielding zombie swinging every tick damages a player at most every half-window") {
assertVanillaCadence("iron-sword", armed = true)
}
test("mob arrows cannot pierce the invulnerability window opened by a melee hit") {
data class ArrowHit(val tick: Int, val finalDamage: Double, val cancelled: Boolean, val noDamageTicks: Int)
val arrowHits = mutableListOf<ArrowHit>()
var meleeLandedTick = -1
lateinit var victimFake: FakePlayer
lateinit var victim: Player
lateinit var zombie: Zombie
lateinit var skeleton: Skeleton
var tick = 0
val totalTicks = 12
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId != victim.uniqueId) return
when (event.damager) {
is Zombie -> if (!event.isCancelled && event.finalDamage > 0.0 && meleeLandedTick < 0) {
meleeLandedTick = tick
}
is AbstractArrow -> arrowHits.add(
ArrowHit(tick, event.finalDamage, event.isCancelled, victim.noDamageTicks)
)
else -> {}
}
}
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val base = Location(world, 40.0, 100.0, 40.0)
victimFake = FakePlayer(plugin)
victimFake.spawn(base)
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
victim.gameMode = GameMode.SURVIVAL
victim.isInvulnerable = false
victim.inventory.clear()
victim.inventory.armorContents = arrayOfNulls(4)
victim.health = victim.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
zombie = world.spawn(base.clone().add(1.1, 0.0, 0.0), Zombie::class.java)
zombie.setAI(false)
zombie.isSilent = true
skeleton = world.spawn(base.clone().add(-2.0, 0.0, 0.0), Skeleton::class.java)
skeleton.setAI(false)
skeleton.isSilent = true
Bukkit.getPluginManager().registerEvents(listener, plugin)
object : BukkitRunnable() {
override fun run() {
tick += 1
if (tick > totalTicks) {
cancel()
return
}
if (victim.health < 10.0) victim.health = victim.maxHealth
if (tick == 1) {
// Melee hit opens the 20-tick window (and OCM zeroes lastHurt next tick)
victim.damage(3.0, zombie)
} else if (tick in 4..10 && tick % 2 == 0) {
// Point-blank arrow aimed at the victim's chest, well inside the window
val eye = victim.location.clone().add(0.0, 1.2, 0.0)
val from = eye.clone().add(0.9, 0.0, 0.0)
val direction = eye.toVector().subtract(from.toVector()).normalize()
val arrow = victim.world.spawnArrow(from, direction, 1.0f, 0.0f)
arrow.shooter = skeleton
}
}
}.runTaskTimer(plugin, 1L, 1L)
}
while (tick <= totalTicks) delayTicks(5)
delayTicks(2)
arrowHits.forEach {
plugin.logger.info(
"MobInvuln[arrow-pierce]: tick=${it.tick} final=${it.finalDamage}" +
" cancelled=${it.cancelled} ndt=${it.noDamageTicks}"
)
}
plugin.logger.info(
"MobInvuln[arrow-pierce]: meleeLandedTick=$meleeLandedTick arrowEvents=${arrowHits.size}"
)
meleeLandedTick shouldBe 1
val halfWindow = runSync { victim.maximumNoDamageTicks } / 2
// Vanilla: a 2-damage arrow with noDamageTicks still above the half-window after
// a 3-damage melee hit is fully rejected (amount <= lastHurt); landing one is a pierce.
// At or below the half-window a full hit is legal, so judge by event-time ticks.
val pierced = arrowHits.filter {
!it.cancelled && it.finalDamage > 0.0 && it.noDamageTicks > halfWindow
}
pierced shouldBe emptyList()
// The gate must actually have engaged on the in-window arrows
arrowHits.count { it.cancelled && it.noDamageTicks > halfWindow } shouldBeGreaterThanOrEqual 2
} finally {
HandlerList.unregisterAll(listener)
runSync {
victimFake.removePlayer()
zombie.remove()
skeleton.remove()
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
}
}
}
test("in-window projectile overdamage deals the vanilla difference after environmental damage") {
data class ArrowHit(
val damage: Double,
val finalDamage: Double,
val cancelled: Boolean,
val noDamageTicks: Int,
val lastDamage: Double
)
val mainHits = mutableListOf<ArrowHit>()
val controlHits = mutableListOf<ArrowHit>()
lateinit var victimFake: FakePlayer
lateinit var victim: Player
lateinit var controlFake: FakePlayer
lateinit var control: Player
lateinit var skeleton: Skeleton
var tick = 0
val totalTicks = 8
fun record(target: Player, sink: MutableList<ArrowHit>, event: EntityDamageByEntityEvent) {
sink.add(
ArrowHit(
event.damage,
event.finalDamage,
event.isCancelled,
target.noDamageTicks,
target.lastDamage
)
)
}
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.damager !is AbstractArrow) return
when (event.entity.uniqueId) {
victim.uniqueId -> record(victim, mainHits, event)
control.uniqueId -> record(control, controlHits, event)
else -> {}
}
}
}
fun setupTarget(player: Player) {
player.gameMode = GameMode.SURVIVAL
player.isInvulnerable = false
player.inventory.clear()
player.inventory.armorContents = arrayOfNulls(4)
player.health = player.maxHealth
player.noDamageTicks = 0
player.lastDamage = 0.0
}
fun fireArrowAt(target: Player) {
val eye = target.location.clone().add(0.0, 1.2, 0.0)
val from = eye.clone().add(0.9, 0.0, 0.0)
val direction = eye.toVector().subtract(from.toVector()).normalize()
val arrow = target.world.spawnArrow(from, direction, 1.0f, 0.0f)
arrow.shooter = skeleton
arrow.damage = 6.0
}
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
val base = Location(world, 50.0, 100.0, 50.0)
victimFake = FakePlayer(plugin)
victimFake.spawn(base)
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
setupTarget(victim)
// Identical setup with no prior damage: its arrow event reveals the true NMS amount
controlFake = FakePlayer(plugin)
controlFake.spawn(base.clone().add(0.0, 0.0, 8.0))
control = checkNotNull(Bukkit.getPlayer(controlFake.uuid))
setupTarget(control)
skeleton = world.spawn(base.clone().add(-2.0, 0.0, 0.0), Skeleton::class.java)
skeleton.setAI(false)
skeleton.isSilent = true
Bukkit.getPluginManager().registerEvents(listener, plugin)
object : BukkitRunnable() {
override fun run() {
tick += 1
if (tick > totalTicks) {
cancel()
return
}
if (tick == 1) {
// Environmental hit opens the window and leaves NMS lastHurt at 2.0
// (the MONITOR zeroing only applies to entity damage)
victim.damage(2.0)
} else if (tick == 3) {
fireArrowAt(victim)
fireArrowAt(control)
}
}
}.runTaskTimer(plugin, 1L, 1L)
}
while (tick <= totalTicks) delayTicks(5)
delayTicks(2)
(mainHits.map { "main" to it } + controlHits.map { "control" to it }).forEach { (tag, hit) ->
plugin.logger.info(
"MobInvuln[env-overdamage]: $tag dmg=${hit.damage} final=${hit.finalDamage}" +
" cancelled=${hit.cancelled} ndt=${hit.noDamageTicks} lastDamage=${hit.lastDamage}"
)
}
controlHits.size shouldBe 1
mainHits.size shouldBe 1
val controlHit = controlHits.single()
val mainHit = mainHits.single()
controlHit.cancelled shouldBe false
mainHit.cancelled shouldBe false
// The control victim has no invulnerability window, so its event damage is the raw
// NMS arrow amount. Vanilla deals amount minus the 2.0 environmental baseline inside
// the window; a double-subtracting gate loses a further 2.0.
mainHit.finalDamage shouldBe (controlHit.damage - 2.0)
} finally {
HandlerList.unregisterAll(listener)
runSync {
victimFake.removePlayer()
controlFake.removePlayer()
skeleton.remove()
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
}
}
}
test("in-window thrown trident deals the configured value minus the baseline") {
if (!CompatibilityCapabilities.isBukkitClassAvailable("org.bukkit.entity.Trident")) {
plugin.logger.info("MobInvuln[trident-window]: no Trident class, skipping")
return@test
}
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val configured = 15.0
val baseline = 2.0
val oldTrident = ocm.config.get("old-tool-damage.damages.TRIDENT_THROWN")
lateinit var victimFake: FakePlayer
lateinit var victim: Player
var landed = -1.0
var tick = 0
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId && event.damager is Trident) {
landed = event.finalDamage
}
}
}
try {
runSync {
ocm.config.set("old-tool-damage.damages.TRIDENT_THROWN", configured)
ocm.saveConfig()
Config.reload()
val world = checkNotNull(Bukkit.getWorld("world"))
val base = Location(world, 70.0, 100.0, 70.0)
victimFake = FakePlayer(plugin)
victimFake.spawn(base)
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
victim.gameMode = GameMode.SURVIVAL
victim.isInvulnerable = false
victim.inventory.clear()
victim.inventory.armorContents = arrayOfNulls(4)
victim.health = victim.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
Bukkit.getPluginManager().registerEvents(listener, plugin)
object : BukkitRunnable() {
override fun run() {
tick += 1
if (tick > 8) {
cancel()
return
}
if (tick == 1) {
// Non-entity damage opens the window and leaves a real NMS baseline
victim.damage(baseline)
} else if (tick == 3) {
val eye = victim.location.clone().add(0.0, 1.2, 0.0)
val from = eye.clone().add(0.9, 0.0, 0.0)
val dir = eye.toVector().subtract(from.toVector()).normalize()
val trident = world.spawn(from, Trident::class.java)
trident.velocity = dir.multiply(0.6)
}
}
}.runTaskTimer(plugin, 1L, 1L)
}
while (tick <= 8) delayTicks(4)
delayTicks(3)
plugin.logger.info("MobInvuln[trident-window]: landed=$landed expected=${configured - baseline}")
// The configured value is a full hit, so the window must subtract the baseline once.
// Adding the baseline back on top of a config constant would deal the full value.
landed shouldBe ((configured - baseline) plusOrMinus 0.001)
} finally {
HandlerList.unregisterAll(listener)
runSync {
ocm.config.set("old-tool-damage.damages.TRIDENT_THROWN", oldTrident)
ocm.saveConfig()
Config.reload()
victimFake.removePlayer()
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
}
}
}
test("armed mob weapon delta scales with hard difficulty") {
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
lateinit var victimFake: FakePlayer
lateinit var victim: Player
lateinit var zombie: Zombie
var landed = -1.0
val world = runSync { checkNotNull(Bukkit.getWorld("world")) }
val oldDifficulty = runSync { world.difficulty }
val configuredIronAxe = ocm.config.getDouble("old-tool-damage.damages.IRON_AXE", -1.0)
val listener = object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onDamage(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId && event.damager is Zombie) {
landed = event.finalDamage
}
}
}
try {
if (configuredIronAxe <= 0.0) {
plugin.logger.info("MobInvuln[hard-delta]: no IRON_AXE damage configured, skipping")
return@test
}
runSync {
world.difficulty = Difficulty.HARD
victimFake = FakePlayer(plugin)
victimFake.spawn(Location(world, 60.0, 100.0, 60.0))
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
victim.gameMode = GameMode.SURVIVAL
victim.isInvulnerable = false
victim.inventory.clear()
victim.inventory.armorContents = arrayOfNulls(4)
victim.health = victim.maxHealth
victim.noDamageTicks = 0
victim.lastDamage = 0.0
zombie = world.spawn(Location(world, 61.0, 100.0, 60.0), Zombie::class.java)
zombie.setAI(false)
zombie.isSilent = true
zombie.equipment?.setItemInMainHand(ItemStack(Material.IRON_AXE))
Bukkit.getPluginManager().registerEvents(listener, plugin)
}
delayTicks(2)
val attribute = runSync {
val attackDamage = checkNotNull(XAttribute.ATTACK_DAMAGE.get())
checkNotNull(zombie.getAttribute(attackDamage)).value
}
// Bukkit damage() drives Player.hurt, so hard difficulty scales this by 1.5 first
runSync { victim.damage(attribute, zombie) }
delayTicks(3)
val vanillaItem = NewWeaponDamage.getDamageOrNull(Material.IRON_AXE)?.toDouble()
val delta = configuredIronAxe - checkNotNull(vanillaItem)
val expected = (attribute + delta) * 1.5
plugin.logger.info(
"MobInvuln[hard-delta]: attribute=$attribute delta=$delta landed=$landed expected=$expected"
)
// The delta must be scaled with the hit, so the result equals the 1.8 value times 1.5
landed shouldBe (expected plusOrMinus 0.001)
} finally {
HandlerList.unregisterAll(listener)
runSync {
world.difficulty = oldDifficulty
victimFake.removePlayer()
zombie.remove()
}
}
}
test("missing attack-frequency keys fall back to the vanilla 20-tick window") {
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
lateinit var victimFake: FakePlayer
lateinit var victim: Player
val oldPlayerDelay = ocm.config.get("attack-frequency.playerDelay")
val oldMobDelay = ocm.config.get("attack-frequency.mobDelay")
try {
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
victimFake = FakePlayer(plugin)
victimFake.spawn(Location(world, 30.0, 100.0, 30.0))
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
ocm.config.set("attack-frequency.playerDelay", null)
ocm.config.set("attack-frequency.mobDelay", null)
ocm.saveConfig()
Config.reload()
}
delayTicks(2)
runSync {
plugin.logger.info(
"MobInvuln[missing-keys]: maximumNoDamageTicks=${victim.maximumNoDamageTicks}"
)
victim.maximumNoDamageTicks shouldBe 20
}
} finally {
runSync {
ocm.config.set("attack-frequency.playerDelay", oldPlayerDelay)
ocm.config.set("attack-frequency.mobDelay", oldMobDelay)
ocm.saveConfig()
Config.reload()
victimFake.removePlayer()
}
}
}
})
@@ -0,0 +1,604 @@
/*
* 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/.
*/
@file:Suppress("ktlint:standard:package-name")
package kernitus.plugin.OldCombatMechanics
import io.kotest.assertions.throwables.shouldThrow
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.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandCompleter
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
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.event.EventHandler
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.plugin.java.JavaPlugin
import java.util.Locale
import java.util.concurrent.atomic.AtomicReference
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 api = Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI::class.java)?.provider
?: error("OldCombatMechanicsAPI service not registered")
val module =
ModuleLoader
.getModules()
.filterIsInstance<ModuleDisableOffHand>()
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
val swordBlockingModule =
ModuleLoader
.getModules()
.filterIsInstance<ModuleSwordBlocking>()
.firstOrNull() ?: error("ModuleSwordBlocking 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<String, Any?>)
}
else -> {
ocm.config.set(path, value)
}
}
}
fun applyConfigWithWorlds(
always: List<String>,
disabled: List<String>,
modesets: Map<String, List<String>>,
worlds: Map<String, List<String>>
) {
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", null)
ocm.config.createSection("worlds", worlds)
ocm.saveConfig()
Config.reload()
}
fun applyConfig(
always: List<String>,
disabled: List<String>,
modesets: Map<String, List<String>>,
worldModesets: List<String>
) {
applyConfigWithWorlds(
always = always,
disabled = disabled,
modesets = modesets,
worlds = mapOf("world" to worldModesets)
)
}
fun completeAlways(
always: List<String>,
disabled: List<String>,
modesets: Map<String, List<String>>
): List<String> {
val assigned = HashSet<String>()
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<String>()
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()
}
fun applySimpleModesetWorlds(worlds: Map<String, List<String>>) {
val modesets =
mapOf(
"old" to listOf("disable-offhand"),
"new" to listOf("old-potion-effects")
)
applyConfigWithWorlds(
always =
completeAlways(
always = emptyList(),
disabled = emptyList(),
modesets = modesets
),
disabled = emptyList(),
modesets = modesets,
worlds = worlds
)
}
fun clearPendingSaveTask() {
val field = PlayerStorage::class.java.getDeclaredField("saveTask")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val saveTask = field.get(null) as AtomicReference<Any?>
val pending = saveTask.getAndSet(null)
pending?.javaClass?.getMethod("cancel")?.invoke(pending)
}
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("stale stored modeset is not trusted when current world only allows another modeset") {
withConfig {
runSync {
applyConfig(
always =
completeAlways(
always = emptyList(),
disabled = emptyList(),
modesets =
mapOf(
"old" to listOf("sword-blocking"),
"new" to listOf("old-potion-effects")
)
),
disabled = emptyList(),
modesets =
mapOf(
"old" to listOf("sword-blocking"),
"new" to listOf("old-potion-effects")
),
worldModesets = listOf("new")
)
setModeset(player, "old")
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, ""))
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
swordBlockingModule.isEnabled(player).shouldBeFalse()
}
}
}
test("listed world modesets override default world modesets") {
withConfig {
runSync {
applySimpleModesetWorlds(
mapOf(
"__default__" to listOf("old"),
"world" to listOf("new", "old")
)
)
Config.getAllowedModesets(player.world.uid).toList().shouldContainExactly("new", "old")
Config.getDefaultModeset(player.world.uid) shouldBe Config.getModesets()["new"]
}
}
}
test("unlisted worlds use default world modesets") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("__default__" to listOf("old")))
Config.getAllowedModesets(player.world.uid).toList().shouldContainExactly("old")
Config.getDefaultModeset(player.world.uid) shouldBe Config.getModesets()["old"]
}
}
}
test("empty listed world modesets remain unrestricted despite default world modesets") {
withConfig {
runSync {
applySimpleModesetWorlds(
mapOf(
"__default__" to listOf("old"),
"world" to emptyList()
)
)
val allowedModesets = Config.getAllowedModesets(player.world.uid)
allowedModesets.shouldContain("old")
allowedModesets.shouldContain("new")
Config.getDefaultModeset(player.world.uid) shouldBe null
}
}
}
test("mode command and tab completion use default world modesets") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("__default__" to listOf("old")))
player.addAttachment(ocm, "oldcombatmechanics.commands", true)
player.addAttachment(ocm, "oldcombatmechanics.mode", true)
player.addAttachment(ocm, "oldcombatmechanics.mode.own", true)
val command = checkNotNull(Bukkit.getPluginCommand("oldcombatmechanics"))
val completions = OCMCommandCompleter().onTabComplete(player, command, "ocm", arrayOf("mode", ""))
completions.shouldContainExactly("old")
setModeset(player, "old")
Bukkit.dispatchCommand(player, "ocm mode new")
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
}
}
}
test("modeset API stores valid modesets and normalises names") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
val events = mutableListOf<PlayerModesetChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onModesetChange(event: PlayerModesetChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
try {
setModeset(player, "old")
api.getModesetNames().toList().shouldContainExactly("old", "new")
api.getAllowedModesets(player.world).toList().shouldContainExactly("old", "new")
api.setModesetForPlayer(player, "new")
api.getModesetForPlayer(player) shouldBe "new"
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
events.single().previousModeset shouldBe "old"
events.single().newModeset shouldBe "new"
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
events.clear()
api.setModesetForPlayer(player, "new")
events shouldBe emptyList()
api.setModesetForPlayer(player, "OLD")
api.getModesetForPlayer(player) shouldBe "old"
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
events.single().previousModeset shouldBe "new"
events.single().newModeset shouldBe "old"
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
} finally {
HandlerList.unregisterAll(listener)
}
}
}
}
test("mode command emits command-reason modeset change events") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
player.addAttachment(ocm, "oldcombatmechanics.commands", true)
player.addAttachment(ocm, "oldcombatmechanics.mode", true)
player.addAttachment(ocm, "oldcombatmechanics.mode.own", true)
setModeset(player, "old")
val events = mutableListOf<PlayerModesetChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onModesetChange(event: PlayerModesetChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
try {
Bukkit.dispatchCommand(player, "ocm mode new") shouldBe true
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
events.single().previousModeset shouldBe "old"
events.single().newModeset shouldBe "new"
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.COMMAND
events.clear()
Bukkit.dispatchCommand(player, "ocm mode new") shouldBe true
events shouldBe emptyList()
} finally {
HandlerList.unregisterAll(listener)
}
}
}
}
test("modeset API rejects modesets disallowed in the player world") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("world" to listOf("old")))
setModeset(player, "old")
shouldThrow<IllegalArgumentException> {
api.setModesetForPlayer(player, "new")
}
api.getModesetForPlayer(player) shouldBe "old"
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
}
}
}
test("modeset API no-op set does not schedule a player data save") {
withConfig {
runSync {
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
setModeset(player, "old")
clearPendingSaveTask()
api.setModesetForPlayer(player, "old")
val field = PlayerStorage::class.java.getDeclaredField("saveTask")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val saveTask = field.get(null) as AtomicReference<Any?>
saveTask.get() shouldBe null
api.getModesetForPlayer(player) shouldBe "old"
}
}
}
test("modules in disabled and another list fail reload") {
withConfig {
runSync {
shouldThrow<IllegalStateException> {
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<IllegalStateException> {
applyConfig(
always = always,
disabled = emptyList(),
modesets = mapOf("old" to emptyList()),
worldModesets = listOf("old")
)
}
}
}
}
})
@@ -0,0 +1,22 @@
/*
* 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<ItemStack>,
val attackDelay: Long,
val message: String,
val preparations: Runnable
)
@@ -0,0 +1,52 @@
/*
* 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
}
}
}
@@ -0,0 +1,174 @@
/*
* 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<ModuleOldArmourDurability>()
.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
}
}
}
})
@@ -0,0 +1,245 @@
/*
* 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/.
*/
@file:Suppress("ktlint:standard:package-name")
package kernitus.plugin.OldCombatMechanics
import com.google.common.base.Function
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.ModuleOldArmourStrength
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.Material
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.EnumMap
import java.util.Locale
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class OldArmourStrengthModesetIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module =
ModuleLoader
.getModules()
.filterIsInstance<ModuleOldArmourStrength>()
.firstOrNull() ?: error("ModuleOldArmourStrength not registered")
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 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<String, Any?>)
}
else -> {
ocm.config.set(path, value)
}
}
}
fun configurableModules(): List<String> {
val internalModules =
setOf(
"modeset-listener",
"attack-cooldown-tracker",
"entity-damage-listener"
)
val optionalModules =
setOf(
"disable-attack-sounds",
"disable-sword-sweep-particles"
)
return (
ModuleLoader
.getModules()
.map { it.configName.lowercase(Locale.ROOT) }
.filterNot { internalModules.contains(it) } + optionalModules
).distinct().sorted()
}
fun reloadConfigAndModules() {
ocm.saveConfig()
Config.reload()
ModuleLoader.toggleModules()
module.reload()
}
fun applyIssue861Config() {
val oldArmourStrength = "old-armour-strength"
ocm.config.set("always_enabled_modules", emptyList<String>())
ocm.config.set("disabled_modules", configurableModules().filterNot { it == oldArmourStrength })
ocm.config.set("modesets", null)
ocm.config.createSection(
"modesets",
mapOf(
"old" to listOf(oldArmourStrength),
"new" to emptyList<String>()
)
)
ocm.config.set("worlds", null)
ocm.config.createSection("worlds", mapOf("world" to listOf("new")))
reloadConfigAndModules()
}
suspend fun withIssue861Config(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 {
applyIssue861Config()
block()
} finally {
runSync {
ocm.config.set("always_enabled_modules", originalAlways)
ocm.config.set("disabled_modules", originalDisabled)
restoreSection("modesets", originalModesets)
restoreSection("worlds", originalWorlds)
reloadConfigAndModules()
}
}
}
fun setModeset(modeset: String) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
setPlayerData(player.uniqueId, playerData)
}
@Suppress("DEPRECATION")
fun createExplosionDamageEvent(): EntityDamageEvent {
val modifiers =
EnumMap<EntityDamageEvent.DamageModifier, Double>(
EntityDamageEvent.DamageModifier::class.java
)
modifiers[EntityDamageEvent.DamageModifier.BASE] = 20.0
modifiers[EntityDamageEvent.DamageModifier.ARMOR] = -8.0
modifiers[EntityDamageEvent.DamageModifier.MAGIC] = -2.0
val identity =
object : Function<Double, Double> {
override fun apply(input: Double): Double = input
}
val modifierFunctions =
EnumMap<EntityDamageEvent.DamageModifier, Function<in Double, Double>>(
EntityDamageEvent.DamageModifier::class.java
)
modifiers.keys.forEach { modifierFunctions[it] = identity }
return EntityDamageEvent(
player,
EntityDamageEvent.DamageCause.BLOCK_EXPLOSION,
modifiers,
modifierFunctions
)
}
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.activePotionEffects.forEach { player.removePotionEffect(it.type) }
player.inventory.chestplate = ItemStack(Material.DIAMOND_CHESTPLATE)
}
}
test("old-armour-strength leaves explosion modifiers unchanged in new modeset") {
withIssue861Config {
runSync {
setModeset("new")
val event = createExplosionDamageEvent()
val originalArmour = event.getDamage(EntityDamageEvent.DamageModifier.ARMOR)
val originalMagic = event.getDamage(EntityDamageEvent.DamageModifier.MAGIC)
val originalFinalDamage = event.finalDamage
Bukkit.getPluginManager().callEvent(event)
event.getDamage(EntityDamageEvent.DamageModifier.ARMOR) shouldBe (originalArmour plusOrMinus 0.0001)
event.getDamage(EntityDamageEvent.DamageModifier.MAGIC) shouldBe (originalMagic plusOrMinus 0.0001)
event.finalDamage shouldBe (originalFinalDamage plusOrMinus 0.0001)
}
}
}
test("old-armour-strength applies old armour modifiers to explosion damage in old modeset") {
withIssue861Config {
runSync {
setModeset("old")
val event = createExplosionDamageEvent()
Bukkit.getPluginManager().callEvent(event)
event.getDamage(EntityDamageEvent.DamageModifier.ARMOR) shouldBe (-6.4 plusOrMinus 0.0001)
event.getDamage(EntityDamageEvent.DamageModifier.MAGIC) shouldBe (0.0 plusOrMinus 0.0001)
event.finalDamage shouldBe (13.6 plusOrMinus 0.0001)
}
}
}
})
@@ -0,0 +1,481 @@
/*
* 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<ModuleOldCriticalHits>()
.firstOrNull() ?: error("ModuleOldCriticalHits not registered")
val toolDamageModule = ModuleLoader.getModules()
.filterIsInstance<ModuleOldToolDamage>()
.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<EntityDamageByEntityEvent>()
val ocmEvents = mutableListOf<OCMEntityDamageByEntityEvent>()
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)
}
}
}
})
@@ -0,0 +1,295 @@
/*
* 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<Method> {
val methods = LinkedHashMap<String, Method>()
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<out LivingEntity> =
try {
@Suppress("UNCHECKED_CAST")
Class.forName("org.bukkit.entity.Vindicator") as Class<out LivingEntity>
} 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
}
}
}
})
@@ -0,0 +1,278 @@
/*
* 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 <T> 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<String>.withoutModule(): MutableList<String> =
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<FakePlayer, Player> {
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<String>, block: suspend () -> Unit) {
val field = module.javaClass.getDeclaredField("blockedSounds")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val current = field.get(module) as MutableSet<String>
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<ModuleAttackSounds>().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<ModuleAttackSounds>().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)
}
}
}
})
@@ -0,0 +1,166 @@
/*
* 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 <T> 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<ModuleSwordBlocking>().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()
}
}
}
})
@@ -0,0 +1,341 @@
/*
* 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.CompatibilityCapabilities
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<ModulePlayerKnockback>()
.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<UUID, Any> {
val field = pendingKnockbackField()
@Suppress("UNCHECKED_CAST")
return field.get(module) as MutableMap<UUID, Any>
}
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>)[uuid] = vector
return
}
val pendingClass = ModulePlayerKnockback::class.java.declaredClasses
.firstOrNull { it.simpleName == "PendingKnockback" }
?: error("PendingKnockback inner class not found")
val ctor = pendingClass.declaredConstructors.firstOrNull { constructor ->
val parameterTypes = constructor.parameterTypes.toList()
parameterTypes == listOf(Vector::class.java, UUID::class.java, Long::class.javaPrimitiveType) ||
parameterTypes == listOf(Vector::class.java, Long::class.javaPrimitiveType)
} ?: error("No supported PendingKnockback constructor found")
ctor.isAccessible = true
val pending = when (ctor.parameterTypes.size) {
3 -> ctor.newInstance(vector, attacker.uniqueId, Long.MAX_VALUE)
else -> 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 (!CompatibilityCapabilities.isKnockbackResistanceAvailable()) 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 (!CompatibilityCapabilities.isKnockbackResistanceAvailable()) 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
}
}
}
}
})
@@ -0,0 +1,480 @@
/*
* 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.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldNotContain
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
import kernitus.plugin.OldCombatMechanics.module.OCMModule
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.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
@OptIn(ExperimentalKotest::class)
class PlayerModuleOverrideApiIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val api = Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI::class.java)?.provider
?: error("OldCombatMechanicsApi service not registered")
val module = ModuleLoader.getModules()
.filterIsInstance<ModuleDisableOffHand>()
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
val alwaysEnabledModule = moduleByName("attack-frequency")
val modesetModule = moduleByName("old-tool-damage")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin) {
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))
}
}
beforeTest {
runSync {
api.clearAllModuleOverridesForPlayer(player)
setModeset(player, "old")
}
}
afterTest {
runSync {
api.clearAllModuleOverridesForPlayer(player)
setModeset(player, "old")
}
}
afterSpec {
runSync {
api.clearAllModuleOverridesForPlayer(player)
fakePlayer.removePlayer()
}
}
test("API can force module enable and disable per player") {
runSync {
val moduleName = module.configName
val disabledModeset = Config.getModesets().entries.firstOrNull { (_, modules) ->
!modules.contains(moduleName)
}?.key
if (disabledModeset != null) {
setModeset(player, disabledModeset)
}
val baseline = module.isEnabled(player)
api.forceEnableModuleForPlayer(player, moduleName)
module.isEnabled(player) shouldBe true
api.forceDisableModuleForPlayer(player, moduleName)
module.isEnabled(player) shouldBe false
api.clearModuleOverrideForPlayer(player, moduleName)
module.isEnabled(player) shouldBe baseline
}
}
test("unknown and internal module names are rejected") {
runSync {
shouldThrow<IllegalArgumentException> {
api.forceEnableModuleForPlayer(player, "not-a-real-module")
}
shouldThrow<IllegalArgumentException> {
api.forceDisableModuleForPlayer(player, "modeset-listener")
}
shouldThrow<IllegalArgumentException> {
api.getModuleOverrideForPlayer(player, "attack-cooldown-tracker")
}
api.hasAnyOverrideForPlayer(player) shouldBe false
}
}
test("bulk override setter rejects invalid keys before mutating overrides") {
runSync {
val moduleName = module.configName
val alwaysEnabledModuleName = alwaysEnabledModule.configName
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
api.forceEnableModuleForPlayer(player, moduleName)
events.clear()
try {
val exception = shouldThrow<IllegalArgumentException> {
api.setModuleOverridesForPlayer(
player,
mapOf(
moduleName to PlayerModuleOverride.FORCE_DISABLED,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
"missing-module" to PlayerModuleOverride.FORCE_ENABLED,
),
)
}
exception.message shouldBe "Unknown or non-configurable module: missing-module"
} finally {
HandlerList.unregisterAll(listener)
}
api.getModuleOverridesForPlayer(player) shouldBe mapOf(moduleName to PlayerModuleOverride.FORCE_ENABLED)
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
events shouldBe emptyList()
}
}
test("bulk override setter rejects duplicate normalised keys before mutating overrides") {
runSync {
val moduleName = module.configName
val alwaysEnabledModuleName = alwaysEnabledModule.configName
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
api.forceEnableModuleForPlayer(player, moduleName)
events.clear()
try {
shouldThrow<IllegalArgumentException> {
api.setModuleOverridesForPlayer(
player,
mapOf(
moduleName to PlayerModuleOverride.FORCE_DISABLED,
" ${moduleName.replaceFirstChar { it.uppercase() }} " to PlayerModuleOverride.DEFAULT,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
),
)
}
} finally {
HandlerList.unregisterAll(listener)
}
api.getModuleOverridesForPlayer(player) shouldBe mapOf(moduleName to PlayerModuleOverride.FORCE_ENABLED)
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
events shouldBe emptyList()
}
}
test("module override API emits only changed override events in sorted bulk order") {
runSync {
val configDisabledModuleName = module.configName
val alwaysEnabledModuleName = alwaysEnabledModule.configName
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
try {
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
events.map { it.moduleName } shouldBe listOf(configDisabledModuleName)
events.single().previousOverride shouldBe PlayerModuleOverride.DEFAULT
events.single().newOverride shouldBe PlayerModuleOverride.FORCE_ENABLED
events.clear()
api.setModuleOverridesForPlayer(
player,
mapOf(
configDisabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
),
)
events.map { it.moduleName } shouldBe listOf(alwaysEnabledModuleName, configDisabledModuleName).sorted()
events.map { it.newOverride } shouldBe listOf(
PlayerModuleOverride.FORCE_DISABLED,
PlayerModuleOverride.FORCE_DISABLED,
)
events.clear()
api.setModuleOverridesForPlayer(
player,
mapOf(
configDisabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
),
)
events shouldBe emptyList()
events.clear()
api.clearAllModuleOverridesForPlayer(player)
events.map { it.moduleName } shouldBe listOf(alwaysEnabledModuleName, configDisabledModuleName).sorted()
events.map { it.newOverride } shouldBe listOf(
PlayerModuleOverride.DEFAULT,
PlayerModuleOverride.DEFAULT,
)
events.clear()
api.clearAllModuleOverridesForPlayer(player)
events shouldBe emptyList()
} finally {
HandlerList.unregisterAll(listener)
}
}
}
test("modeset API emits events only for stored changes") {
runSync {
setModeset(player, "old")
val events = mutableListOf<PlayerModesetChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onModesetChange(event: PlayerModesetChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
try {
api.setModesetForPlayer(player, "new")
events.size shouldBe 1
events.single().previousModeset shouldBe "old"
events.single().newModeset shouldBe "new"
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
api.setModesetForPlayer(player, "new")
events.size shouldBe 1
} finally {
HandlerList.unregisterAll(listener)
}
}
}
test("modeset command emits command reason event") {
runSync {
setModeset(player, "old")
val events = mutableListOf<PlayerModesetChangeEvent>()
val listener = object : Listener {
@EventHandler
fun onModesetChange(event: PlayerModesetChangeEvent) {
if (event.player.uniqueId == player.uniqueId) {
events += event
}
}
}
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
try {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "ocm mode new ${player.name}") shouldBe true
events.size shouldBe 1
events.single().previousModeset shouldBe "old"
events.single().newModeset shouldBe "new"
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.COMMAND
} finally {
HandlerList.unregisterAll(listener)
}
}
}
test("Java null bulk override value is rejected before mutating overrides") {
runSync {
val moduleName = module.configName
api.forceEnableModuleForPlayer(player, moduleName)
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.FORCE_ENABLED
shouldThrow<IllegalArgumentException> {
callJavaNullBulkOverrideValue(api, player, moduleName)
}
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.FORCE_ENABLED
api.clearModuleOverrideForPlayer(player, moduleName)
}
}
test("force enable overrides config-disabled and modeset-disabled modules") {
runSync {
val configDisabledModuleName = module.configName
module.isEnabled(player) shouldBe false
api.isModuleEnabledForPlayer(player, configDisabledModuleName) shouldBe false
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
module.isEnabled(player) shouldBe true
api.isModuleEnabledForPlayer(player, configDisabledModuleName) shouldBe true
val modesetModuleName = modesetModule.configName
setModeset(player, "new")
modesetModule.isEnabled(player) shouldBe false
api.isModuleEnabledForPlayer(player, modesetModuleName) shouldBe false
api.forceEnableModuleForPlayer(player, modesetModuleName)
modesetModule.isEnabled(player) shouldBe true
api.isModuleEnabledForPlayer(player, modesetModuleName) shouldBe true
}
}
test("force disable overrides config-enabled modules") {
runSync {
val moduleName = alwaysEnabledModule.configName
alwaysEnabledModule.isEnabled(player) shouldBe true
api.isModuleEnabledForPlayer(player, moduleName) shouldBe true
api.forceDisableModuleForPlayer(player, moduleName)
alwaysEnabledModule.isEnabled(player) shouldBe false
api.isModuleEnabledForPlayer(player, moduleName) shouldBe false
}
}
test("clearing overrides returns to configured and modeset behaviour") {
runSync {
val configDisabledModuleName = module.configName
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
api.clearModuleOverrideForPlayer(player, configDisabledModuleName)
api.getModuleOverrideForPlayer(player, configDisabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
module.isEnabled(player) shouldBe false
val alwaysEnabledModuleName = alwaysEnabledModule.configName
api.forceDisableModuleForPlayer(player, alwaysEnabledModuleName)
api.clearModuleOverrideForPlayer(player, alwaysEnabledModuleName)
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
alwaysEnabledModule.isEnabled(player) shouldBe true
val modesetModuleName = modesetModule.configName
setModeset(player, "new")
api.forceEnableModuleForPlayer(player, modesetModuleName)
api.clearModuleOverrideForPlayer(player, modesetModuleName)
modesetModule.isEnabled(player) shouldBe false
setModeset(player, "old")
modesetModule.isEnabled(player) shouldBe true
}
}
test("override query methods expose only active configurable overrides") {
runSync {
val configDisabledModuleName = module.configName
val alwaysEnabledModuleName = alwaysEnabledModule.configName
val moduleNames = api.getModuleNames()
moduleNames shouldContain configDisabledModuleName
moduleNames shouldContain alwaysEnabledModuleName
moduleNames shouldContain modesetModule.configName
moduleNames shouldNotContain "modeset-listener"
moduleNames shouldNotContain "attack-cooldown-tracker"
moduleNames shouldNotContain "entity-damage-listener"
api.hasAnyOverrideForPlayer(player) shouldBe false
api.setModuleOverridesForPlayer(
player,
mapOf(
configDisabledModuleName to PlayerModuleOverride.FORCE_ENABLED,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
),
)
api.hasAnyOverrideForPlayer(player) shouldBe true
api.getModuleOverridesForPlayer(player) shouldBe mapOf(
configDisabledModuleName to PlayerModuleOverride.FORCE_ENABLED,
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
)
api.setModuleOverridesForPlayer(
player,
mapOf(configDisabledModuleName to PlayerModuleOverride.DEFAULT),
)
api.getModuleOverridesForPlayer(player) shouldBe mapOf(
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
)
api.clearAllModuleOverridesForPlayer(player)
api.hasAnyOverrideForPlayer(player) shouldBe false
}
}
test("player quit lifecycle clears override state") {
runSync {
val moduleName = module.configName
api.forceEnableModuleForPlayer(player, moduleName)
api.hasAnyOverrideForPlayer(player) shouldBe true
Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, ""))
api.hasAnyOverrideForPlayer(player) shouldBe false
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.DEFAULT
}
}
})
private fun moduleByName(moduleName: String): OCMModule =
ModuleLoader.getModules()
.firstOrNull { it.configName == moduleName }
?: error("$moduleName module not registered")
private fun callJavaNullBulkOverrideValue(api: OldCombatMechanicsAPI, player: Player, moduleName: String) {
val helper = Class.forName("kernitus.plugin.OldCombatMechanics.PlayerModuleOverrideJavaInterop")
val method = helper.getDeclaredMethod(
"setOverridesWithNullValue",
OldCombatMechanicsAPI::class.java,
Player::class.java,
String::class.java,
)
method.isAccessible = true
try {
method.invoke(null, api, player, moduleName)
} catch (exception: java.lang.reflect.InvocationTargetException) {
throw exception.cause ?: exception
}
}
@@ -0,0 +1,243 @@
/*
* 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.suspendCancellableCoroutine
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
import kotlin.coroutines.resume
@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<ModulePlayerRegen>().firstOrNull()
?: error("ModulePlayerRegen not registered")
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun <T> 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 waitServerTicks(ticks: Long) {
suspendCancellableCoroutine { continuation ->
val task = Bukkit.getScheduler().runTaskLater(testPlugin, Runnable {
if (continuation.isActive) {
continuation.resume(Unit)
}
}, ticks)
continuation.invokeOnCancellation { task.cancel() }
}
}
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<Any>(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<Any, Any>)?.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
}
waitServerTicks(2L)
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
}
waitServerTicks(2L)
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)
}
}
}
})
@@ -0,0 +1,106 @@
/*
* 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<Throwable>(
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<String, Any, String>(
{ _, _ ->
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<String, Any, String>(
{ _, _ -> throw IllegalStateException("business logic failure") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow<IllegalStateException> {
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<String, Any, String>(
{ _, _ -> throw NullPointerException("unexpected null") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow<NullPointerException> {
chooser.apply("target", Any())
}
fallbackCalls.get() shouldBe 0
}
"generic unsupported operation with incompatible wording is surfaced" {
val fallbackCalls = AtomicInteger(0)
val chooser =
SpigotFunctionChooser.apiCompatCall<String, Any, String>(
{ _, _ -> throw UnsupportedOperationException("incompatible state for this action") },
{ _, _ ->
fallbackCalls.incrementAndGet()
"fallback"
},
)
shouldThrow<UnsupportedOperationException> {
chooser.apply("target", Any())
}
fallbackCalls.get() shouldBe 0
}
})
private class CompatibilityUnsupportedOperationException(
message: String,
) : UnsupportedOperationException(message)
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more