Compare commits

...
275 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
165 changed files with 27742 additions and 3097 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
+33 -645
View File
@@ -5,7 +5,36 @@
-->
# Contributing
All contributions should be in the form of [pull requests](https://github.com/kernitus/BukkitOldCombatMechanics/pulls), and should use the formatting profiles provided at the bottom of this page. All pull requests must be fully functional and able to compile, and should be fully tested. Please submit **monofocal** pull requests, i.e.if you're making unrelated changes to two different modules, or decide to also update the version of a dependency, *those should be separate pull requests*.
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
```
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.
@@ -22,648 +51,7 @@ There is relative freedom when it codes to coding style, but please adhere to th
* Use `if !condition return;` pattern to avoid eccessive nested if-statements. That is, to check multiple prerequisite conditions, invert the condition and return, and after returning continue with the code.
* Use internal utilies where possible. There is a `Messenger` class for sending messages to users or console, a `Reflector` class for simple reflection, and `DualVersionedMaterial` for support across minecraft versions where the Bukkit item names have changed. There are many more in the `utilities` package, the usage of which can be found in already existing modules.
## Formatting profiles
Please use the [default formatting profile](https://www.jetbrains.com/help/idea/reformat-and-rearrange-code.html) provided with IntelliJ. If using a different IDE, here is the [.editconfig](https://www.jetbrains.com/help/idea/configuring-code-style.html#editorconfig) file for those same settings:
## 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.
```
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
tab_width = 4
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = false
ij_smart_tabs = false
ij_visual_guides = none
ij_wrap_on_typing = false
[*.css]
ij_css_align_closing_brace_with_properties = false
ij_css_blank_lines_around_nested_selector = 1
ij_css_blank_lines_between_blocks = 1
ij_css_brace_placement = end_of_line
ij_css_enforce_quotes_on_format = false
ij_css_hex_color_long_format = false
ij_css_hex_color_lower_case = false
ij_css_hex_color_short_format = false
ij_css_hex_color_upper_case = false
ij_css_keep_blank_lines_in_code = 2
ij_css_keep_indents_on_empty_lines = false
ij_css_keep_single_line_blocks = false
ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_css_space_after_colon = true
ij_css_space_before_opening_brace = true
ij_css_use_double_quotes = true
ij_css_value_alignment = do_not_align
[*.feature]
indent_size = 2
ij_gherkin_keep_indents_on_empty_lines = false
[*.gsp]
ij_gsp_keep_indents_on_empty_lines = false
[*.haml]
indent_size = 2
ij_haml_keep_indents_on_empty_lines = false
[*.java]
ij_java_align_consecutive_assignments = false
ij_java_align_consecutive_variable_declarations = false
ij_java_align_group_field_declarations = false
ij_java_align_multiline_annotation_parameters = false
ij_java_align_multiline_array_initializer_expression = false
ij_java_align_multiline_assignment = false
ij_java_align_multiline_binary_operation = false
ij_java_align_multiline_chained_methods = false
ij_java_align_multiline_extends_list = false
ij_java_align_multiline_for = true
ij_java_align_multiline_method_parentheses = false
ij_java_align_multiline_parameters = true
ij_java_align_multiline_parameters_in_calls = false
ij_java_align_multiline_parenthesized_expression = false
ij_java_align_multiline_records = true
ij_java_align_multiline_resources = true
ij_java_align_multiline_ternary_operation = false
ij_java_align_multiline_text_blocks = false
ij_java_align_multiline_throws_list = false
ij_java_align_subsequent_simple_methods = false
ij_java_align_throws_keyword = false
ij_java_annotation_parameter_wrap = off
ij_java_array_initializer_new_line_after_left_brace = false
ij_java_array_initializer_right_brace_on_new_line = false
ij_java_array_initializer_wrap = off
ij_java_assert_statement_colon_on_next_line = false
ij_java_assert_statement_wrap = off
ij_java_assignment_wrap = off
ij_java_binary_operation_sign_on_next_line = false
ij_java_binary_operation_wrap = off
ij_java_blank_lines_after_anonymous_class_header = 0
ij_java_blank_lines_after_class_header = 0
ij_java_blank_lines_after_imports = 1
ij_java_blank_lines_after_package = 1
ij_java_blank_lines_around_class = 1
ij_java_blank_lines_around_field = 0
ij_java_blank_lines_around_field_in_interface = 0
ij_java_blank_lines_around_initializer = 1
ij_java_blank_lines_around_method = 1
ij_java_blank_lines_around_method_in_interface = 1
ij_java_blank_lines_before_class_end = 0
ij_java_blank_lines_before_imports = 1
ij_java_blank_lines_before_method_body = 0
ij_java_blank_lines_before_package = 0
ij_java_block_brace_style = end_of_line
ij_java_block_comment_at_first_column = true
ij_java_call_parameters_new_line_after_left_paren = false
ij_java_call_parameters_right_paren_on_new_line = false
ij_java_call_parameters_wrap = off
ij_java_case_statement_on_separate_line = true
ij_java_catch_on_new_line = false
ij_java_class_annotation_wrap = split_into_lines
ij_java_class_brace_style = end_of_line
ij_java_class_count_to_use_import_on_demand = 5
ij_java_class_names_in_javadoc = 1
ij_java_do_not_indent_top_level_class_members = false
ij_java_do_not_wrap_after_single_annotation = false
ij_java_do_while_brace_force = never
ij_java_doc_add_blank_line_after_description = true
ij_java_doc_add_blank_line_after_param_comments = false
ij_java_doc_add_blank_line_after_return = false
ij_java_doc_add_p_tag_on_empty_lines = true
ij_java_doc_align_exception_comments = true
ij_java_doc_align_param_comments = true
ij_java_doc_do_not_wrap_if_one_line = false
ij_java_doc_enable_formatting = true
ij_java_doc_enable_leading_asterisks = true
ij_java_doc_indent_on_continuation = false
ij_java_doc_keep_empty_lines = true
ij_java_doc_keep_empty_parameter_tag = true
ij_java_doc_keep_empty_return_tag = true
ij_java_doc_keep_empty_throws_tag = true
ij_java_doc_keep_invalid_tags = true
ij_java_doc_param_description_on_new_line = false
ij_java_doc_preserve_line_breaks = false
ij_java_doc_use_throws_not_exception_tag = true
ij_java_else_on_new_line = false
ij_java_entity_dd_suffix = EJB
ij_java_entity_eb_suffix = Bean
ij_java_entity_hi_suffix = Home
ij_java_entity_lhi_prefix = Local
ij_java_entity_lhi_suffix = Home
ij_java_entity_li_prefix = Local
ij_java_entity_pk_class = java.lang.String
ij_java_entity_vo_suffix = VO
ij_java_enum_constants_wrap = off
ij_java_extends_keyword_wrap = off
ij_java_extends_list_wrap = off
ij_java_field_annotation_wrap = split_into_lines
ij_java_finally_on_new_line = false
ij_java_for_brace_force = never
ij_java_for_statement_new_line_after_left_paren = false
ij_java_for_statement_right_paren_on_new_line = false
ij_java_for_statement_wrap = off
ij_java_generate_final_locals = false
ij_java_generate_final_parameters = false
ij_java_if_brace_force = never
ij_java_imports_layout = *,|,javax.**,java.**,|,$*
ij_java_indent_case_from_switch = true
ij_java_insert_inner_class_imports = false
ij_java_insert_override_annotation = true
ij_java_keep_blank_lines_before_right_brace = 2
ij_java_keep_blank_lines_between_package_declaration_and_header = 2
ij_java_keep_blank_lines_in_code = 2
ij_java_keep_blank_lines_in_declarations = 2
ij_java_keep_control_statement_in_one_line = true
ij_java_keep_first_column_comment = true
ij_java_keep_indents_on_empty_lines = false
ij_java_keep_line_breaks = true
ij_java_keep_multiple_expressions_in_one_line = false
ij_java_keep_simple_blocks_in_one_line = false
ij_java_keep_simple_classes_in_one_line = false
ij_java_keep_simple_lambdas_in_one_line = false
ij_java_keep_simple_methods_in_one_line = false
ij_java_label_indent_absolute = false
ij_java_label_indent_size = 0
ij_java_lambda_brace_style = end_of_line
ij_java_layout_static_imports_separately = true
ij_java_line_comment_add_space = false
ij_java_line_comment_at_first_column = true
ij_java_message_dd_suffix = EJB
ij_java_message_eb_suffix = Bean
ij_java_method_annotation_wrap = split_into_lines
ij_java_method_brace_style = end_of_line
ij_java_method_call_chain_wrap = off
ij_java_method_parameters_new_line_after_left_paren = false
ij_java_method_parameters_right_paren_on_new_line = false
ij_java_method_parameters_wrap = off
ij_java_modifier_list_wrap = false
ij_java_names_count_to_use_import_on_demand = 3
ij_java_new_line_after_lparen_in_record_header = false
ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.*
ij_java_parameter_annotation_wrap = off
ij_java_parentheses_expression_new_line_after_left_paren = false
ij_java_parentheses_expression_right_paren_on_new_line = false
ij_java_place_assignment_sign_on_next_line = false
ij_java_prefer_longer_names = true
ij_java_prefer_parameters_wrap = false
ij_java_record_components_wrap = normal
ij_java_repeat_synchronized = true
ij_java_replace_instanceof_and_cast = false
ij_java_replace_null_check = true
ij_java_replace_sum_lambda_with_method_ref = true
ij_java_resource_list_new_line_after_left_paren = false
ij_java_resource_list_right_paren_on_new_line = false
ij_java_resource_list_wrap = off
ij_java_rparen_on_new_line_in_record_header = false
ij_java_session_dd_suffix = EJB
ij_java_session_eb_suffix = Bean
ij_java_session_hi_suffix = Home
ij_java_session_lhi_prefix = Local
ij_java_session_lhi_suffix = Home
ij_java_session_li_prefix = Local
ij_java_session_si_suffix = Service
ij_java_space_after_closing_angle_bracket_in_type_argument = false
ij_java_space_after_colon = true
ij_java_space_after_comma = true
ij_java_space_after_comma_in_type_arguments = true
ij_java_space_after_for_semicolon = true
ij_java_space_after_quest = true
ij_java_space_after_type_cast = true
ij_java_space_before_annotation_array_initializer_left_brace = false
ij_java_space_before_annotation_parameter_list = false
ij_java_space_before_array_initializer_left_brace = false
ij_java_space_before_catch_keyword = true
ij_java_space_before_catch_left_brace = true
ij_java_space_before_catch_parentheses = true
ij_java_space_before_class_left_brace = true
ij_java_space_before_colon = true
ij_java_space_before_colon_in_foreach = true
ij_java_space_before_comma = false
ij_java_space_before_do_left_brace = true
ij_java_space_before_else_keyword = true
ij_java_space_before_else_left_brace = true
ij_java_space_before_finally_keyword = true
ij_java_space_before_finally_left_brace = true
ij_java_space_before_for_left_brace = true
ij_java_space_before_for_parentheses = true
ij_java_space_before_for_semicolon = false
ij_java_space_before_if_left_brace = true
ij_java_space_before_if_parentheses = true
ij_java_space_before_method_call_parentheses = false
ij_java_space_before_method_left_brace = true
ij_java_space_before_method_parentheses = false
ij_java_space_before_opening_angle_bracket_in_type_parameter = false
ij_java_space_before_quest = true
ij_java_space_before_switch_left_brace = true
ij_java_space_before_switch_parentheses = true
ij_java_space_before_synchronized_left_brace = true
ij_java_space_before_synchronized_parentheses = true
ij_java_space_before_try_left_brace = true
ij_java_space_before_try_parentheses = true
ij_java_space_before_type_parameter_list = false
ij_java_space_before_while_keyword = true
ij_java_space_before_while_left_brace = true
ij_java_space_before_while_parentheses = true
ij_java_space_inside_one_line_enum_braces = false
ij_java_space_within_empty_array_initializer_braces = false
ij_java_space_within_empty_method_call_parentheses = false
ij_java_space_within_empty_method_parentheses = false
ij_java_spaces_around_additive_operators = true
ij_java_spaces_around_assignment_operators = true
ij_java_spaces_around_bitwise_operators = true
ij_java_spaces_around_equality_operators = true
ij_java_spaces_around_lambda_arrow = true
ij_java_spaces_around_logical_operators = true
ij_java_spaces_around_method_ref_dbl_colon = false
ij_java_spaces_around_multiplicative_operators = true
ij_java_spaces_around_relational_operators = true
ij_java_spaces_around_shift_operators = true
ij_java_spaces_around_type_bounds_in_type_parameters = true
ij_java_spaces_around_unary_operator = false
ij_java_spaces_within_angle_brackets = false
ij_java_spaces_within_annotation_parentheses = false
ij_java_spaces_within_array_initializer_braces = false
ij_java_spaces_within_braces = false
ij_java_spaces_within_brackets = false
ij_java_spaces_within_cast_parentheses = false
ij_java_spaces_within_catch_parentheses = false
ij_java_spaces_within_for_parentheses = false
ij_java_spaces_within_if_parentheses = false
ij_java_spaces_within_method_call_parentheses = false
ij_java_spaces_within_method_parentheses = false
ij_java_spaces_within_parentheses = false
ij_java_spaces_within_record_header = false
ij_java_spaces_within_switch_parentheses = false
ij_java_spaces_within_synchronized_parentheses = false
ij_java_spaces_within_try_parentheses = false
ij_java_spaces_within_while_parentheses = false
ij_java_special_else_if_treatment = true
ij_java_subclass_name_suffix = Impl
ij_java_ternary_operation_signs_on_next_line = false
ij_java_ternary_operation_wrap = off
ij_java_test_name_suffix = Test
ij_java_throws_keyword_wrap = off
ij_java_throws_list_wrap = off
ij_java_use_external_annotations = false
ij_java_use_fq_class_names = false
ij_java_use_relative_indents = false
ij_java_use_single_class_imports = true
ij_java_variable_annotation_wrap = off
ij_java_visibility = public
ij_java_while_brace_force = never
ij_java_while_on_new_line = false
ij_java_wrap_comments = false
ij_java_wrap_first_method_in_call_chain = false
ij_java_wrap_long_lines = false
[.editorconfig]
ij_editorconfig_align_group_field_declarations = false
ij_editorconfig_space_after_colon = false
ij_editorconfig_space_after_comma = true
ij_editorconfig_space_before_colon = false
ij_editorconfig_space_before_comma = false
ij_editorconfig_spaces_around_assignment_operators = true
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_xml_align_attributes = true
ij_xml_align_text = false
ij_xml_attribute_wrap = normal
ij_xml_block_comment_at_first_column = true
ij_xml_keep_blank_lines = 2
ij_xml_keep_indents_on_empty_lines = false
ij_xml_keep_line_breaks = true
ij_xml_keep_line_breaks_in_text = true
ij_xml_keep_whitespaces = false
ij_xml_keep_whitespaces_around_cdata = preserve
ij_xml_keep_whitespaces_inside_cdata = false
ij_xml_line_comment_at_first_column = true
ij_xml_space_after_tag_name = false
ij_xml_space_around_equals_in_attribute = false
ij_xml_space_inside_empty_tag = false
ij_xml_text_wrap = normal
ij_xml_use_custom_settings = false
[{*.ft,*.vm,*.vsl}]
ij_vtl_keep_indents_on_empty_lines = false
[{*.gant,*.gradle,*.groovy,*.gson,*.gy}]
ij_groovy_align_group_field_declarations = false
ij_groovy_align_multiline_array_initializer_expression = false
ij_groovy_align_multiline_assignment = false
ij_groovy_align_multiline_binary_operation = false
ij_groovy_align_multiline_chained_methods = false
ij_groovy_align_multiline_extends_list = false
ij_groovy_align_multiline_for = true
ij_groovy_align_multiline_list_or_map = true
ij_groovy_align_multiline_method_parentheses = false
ij_groovy_align_multiline_parameters = true
ij_groovy_align_multiline_parameters_in_calls = false
ij_groovy_align_multiline_resources = true
ij_groovy_align_multiline_ternary_operation = false
ij_groovy_align_multiline_throws_list = false
ij_groovy_align_named_args_in_map = true
ij_groovy_align_throws_keyword = false
ij_groovy_array_initializer_new_line_after_left_brace = false
ij_groovy_array_initializer_right_brace_on_new_line = false
ij_groovy_array_initializer_wrap = off
ij_groovy_assert_statement_wrap = off
ij_groovy_assignment_wrap = off
ij_groovy_binary_operation_wrap = off
ij_groovy_blank_lines_after_class_header = 0
ij_groovy_blank_lines_after_imports = 1
ij_groovy_blank_lines_after_package = 1
ij_groovy_blank_lines_around_class = 1
ij_groovy_blank_lines_around_field = 0
ij_groovy_blank_lines_around_field_in_interface = 0
ij_groovy_blank_lines_around_method = 1
ij_groovy_blank_lines_around_method_in_interface = 1
ij_groovy_blank_lines_before_imports = 1
ij_groovy_blank_lines_before_method_body = 0
ij_groovy_blank_lines_before_package = 0
ij_groovy_block_brace_style = end_of_line
ij_groovy_block_comment_at_first_column = true
ij_groovy_call_parameters_new_line_after_left_paren = false
ij_groovy_call_parameters_right_paren_on_new_line = false
ij_groovy_call_parameters_wrap = off
ij_groovy_catch_on_new_line = false
ij_groovy_class_annotation_wrap = split_into_lines
ij_groovy_class_brace_style = end_of_line
ij_groovy_class_count_to_use_import_on_demand = 5
ij_groovy_do_while_brace_force = never
ij_groovy_else_on_new_line = false
ij_groovy_enum_constants_wrap = off
ij_groovy_extends_keyword_wrap = off
ij_groovy_extends_list_wrap = off
ij_groovy_field_annotation_wrap = split_into_lines
ij_groovy_finally_on_new_line = false
ij_groovy_for_brace_force = never
ij_groovy_for_statement_new_line_after_left_paren = false
ij_groovy_for_statement_right_paren_on_new_line = false
ij_groovy_for_statement_wrap = off
ij_groovy_if_brace_force = never
ij_groovy_import_annotation_wrap = 2
ij_groovy_imports_layout = *,|,javax.**,java.**,|,$*
ij_groovy_indent_case_from_switch = true
ij_groovy_indent_label_blocks = true
ij_groovy_insert_inner_class_imports = false
ij_groovy_keep_blank_lines_before_right_brace = 2
ij_groovy_keep_blank_lines_in_code = 2
ij_groovy_keep_blank_lines_in_declarations = 2
ij_groovy_keep_control_statement_in_one_line = true
ij_groovy_keep_first_column_comment = true
ij_groovy_keep_indents_on_empty_lines = false
ij_groovy_keep_line_breaks = true
ij_groovy_keep_multiple_expressions_in_one_line = false
ij_groovy_keep_simple_blocks_in_one_line = false
ij_groovy_keep_simple_classes_in_one_line = true
ij_groovy_keep_simple_lambdas_in_one_line = true
ij_groovy_keep_simple_methods_in_one_line = true
ij_groovy_label_indent_absolute = false
ij_groovy_label_indent_size = 0
ij_groovy_lambda_brace_style = end_of_line
ij_groovy_layout_static_imports_separately = true
ij_groovy_line_comment_add_space = false
ij_groovy_line_comment_at_first_column = true
ij_groovy_method_annotation_wrap = split_into_lines
ij_groovy_method_brace_style = end_of_line
ij_groovy_method_call_chain_wrap = off
ij_groovy_method_parameters_new_line_after_left_paren = false
ij_groovy_method_parameters_right_paren_on_new_line = false
ij_groovy_method_parameters_wrap = off
ij_groovy_modifier_list_wrap = false
ij_groovy_names_count_to_use_import_on_demand = 3
ij_groovy_parameter_annotation_wrap = off
ij_groovy_parentheses_expression_new_line_after_left_paren = false
ij_groovy_parentheses_expression_right_paren_on_new_line = false
ij_groovy_prefer_parameters_wrap = false
ij_groovy_resource_list_new_line_after_left_paren = false
ij_groovy_resource_list_right_paren_on_new_line = false
ij_groovy_resource_list_wrap = off
ij_groovy_space_after_assert_separator = true
ij_groovy_space_after_colon = true
ij_groovy_space_after_comma = true
ij_groovy_space_after_comma_in_type_arguments = true
ij_groovy_space_after_for_semicolon = true
ij_groovy_space_after_quest = true
ij_groovy_space_after_type_cast = true
ij_groovy_space_before_annotation_parameter_list = false
ij_groovy_space_before_array_initializer_left_brace = false
ij_groovy_space_before_assert_separator = false
ij_groovy_space_before_catch_keyword = true
ij_groovy_space_before_catch_left_brace = true
ij_groovy_space_before_catch_parentheses = true
ij_groovy_space_before_class_left_brace = true
ij_groovy_space_before_closure_left_brace = true
ij_groovy_space_before_colon = true
ij_groovy_space_before_comma = false
ij_groovy_space_before_do_left_brace = true
ij_groovy_space_before_else_keyword = true
ij_groovy_space_before_else_left_brace = true
ij_groovy_space_before_finally_keyword = true
ij_groovy_space_before_finally_left_brace = true
ij_groovy_space_before_for_left_brace = true
ij_groovy_space_before_for_parentheses = true
ij_groovy_space_before_for_semicolon = false
ij_groovy_space_before_if_left_brace = true
ij_groovy_space_before_if_parentheses = true
ij_groovy_space_before_method_call_parentheses = false
ij_groovy_space_before_method_left_brace = true
ij_groovy_space_before_method_parentheses = false
ij_groovy_space_before_quest = true
ij_groovy_space_before_switch_left_brace = true
ij_groovy_space_before_switch_parentheses = true
ij_groovy_space_before_synchronized_left_brace = true
ij_groovy_space_before_synchronized_parentheses = true
ij_groovy_space_before_try_left_brace = true
ij_groovy_space_before_try_parentheses = true
ij_groovy_space_before_while_keyword = true
ij_groovy_space_before_while_left_brace = true
ij_groovy_space_before_while_parentheses = true
ij_groovy_space_in_named_argument = true
ij_groovy_space_in_named_argument_before_colon = false
ij_groovy_space_within_empty_array_initializer_braces = false
ij_groovy_space_within_empty_method_call_parentheses = false
ij_groovy_spaces_around_additive_operators = true
ij_groovy_spaces_around_assignment_operators = true
ij_groovy_spaces_around_bitwise_operators = true
ij_groovy_spaces_around_equality_operators = true
ij_groovy_spaces_around_lambda_arrow = true
ij_groovy_spaces_around_logical_operators = true
ij_groovy_spaces_around_multiplicative_operators = true
ij_groovy_spaces_around_regex_operators = true
ij_groovy_spaces_around_relational_operators = true
ij_groovy_spaces_around_shift_operators = true
ij_groovy_spaces_within_annotation_parentheses = false
ij_groovy_spaces_within_array_initializer_braces = false
ij_groovy_spaces_within_braces = true
ij_groovy_spaces_within_brackets = false
ij_groovy_spaces_within_cast_parentheses = false
ij_groovy_spaces_within_catch_parentheses = false
ij_groovy_spaces_within_for_parentheses = false
ij_groovy_spaces_within_gstring_injection_braces = false
ij_groovy_spaces_within_if_parentheses = false
ij_groovy_spaces_within_list_or_map = false
ij_groovy_spaces_within_method_call_parentheses = false
ij_groovy_spaces_within_method_parentheses = false
ij_groovy_spaces_within_parentheses = false
ij_groovy_spaces_within_switch_parentheses = false
ij_groovy_spaces_within_synchronized_parentheses = false
ij_groovy_spaces_within_try_parentheses = false
ij_groovy_spaces_within_tuple_expression = false
ij_groovy_spaces_within_while_parentheses = false
ij_groovy_special_else_if_treatment = true
ij_groovy_ternary_operation_wrap = off
ij_groovy_throws_keyword_wrap = off
ij_groovy_throws_list_wrap = off
ij_groovy_use_flying_geese_braces = false
ij_groovy_use_fq_class_names = false
ij_groovy_use_fq_class_names_in_javadoc = true
ij_groovy_use_relative_indents = false
ij_groovy_use_single_class_imports = true
ij_groovy_variable_annotation_wrap = off
ij_groovy_while_brace_force = never
ij_groovy_while_on_new_line = false
ij_groovy_wrap_long_lines = false
[{*.gradle.kts,*.kt,*.kts,*.main.kts}]
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_allow_trailing_comma = false
ij_kotlin_allow_trailing_comma_on_call_site = false
ij_kotlin_assignment_wrap = normal
ij_kotlin_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = true
ij_kotlin_call_parameters_right_paren_on_new_line = true
ij_kotlin_call_parameters_wrap = on_every_item
ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ij_kotlin_continuation_indent_for_chained_calls = false
ij_kotlin_continuation_indent_for_expression_bodies = false
ij_kotlin_continuation_indent_in_argument_lists = false
ij_kotlin_continuation_indent_in_elvis = false
ij_kotlin_continuation_indent_in_if_conditions = false
ij_kotlin_continuation_indent_in_parameter_lists = false
ij_kotlin_continuation_indent_in_supertype_lists = false
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = normal
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = true
ij_kotlin_import_nested_classes = false
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = true
ij_kotlin_method_parameters_right_paren_on_new_line = true
ij_kotlin_method_parameters_wrap = on_every_item
ij_kotlin_name_count_to_use_star_import = 5
ij_kotlin_name_count_to_use_star_import_for_members = 3
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false
[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
ij_html_align_attributes = true
ij_html_align_text = false
ij_html_attribute_wrap = normal
ij_html_block_comment_at_first_column = true
ij_html_do_not_align_children_of_min_lines = 0
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
ij_html_enforce_quotes = false
ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var
ij_html_keep_blank_lines = 2
ij_html_keep_indents_on_empty_lines = false
ij_html_keep_line_breaks = true
ij_html_keep_line_breaks_in_text = true
ij_html_keep_whitespaces = false
ij_html_keep_whitespaces_inside = span,pre,textarea
ij_html_line_comment_at_first_column = true
ij_html_new_line_after_last_attribute = never
ij_html_new_line_before_first_attribute = never
ij_html_quote_style = double
ij_html_remove_new_line_before_tags = br
ij_html_space_after_tag_name = false
ij_html_space_around_equality_in_attribute = false
ij_html_space_inside_empty_tag = false
ij_html_text_wrap = normal
ij_html_uniform_ident = false
[{*.markdown,*.md}]
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
ij_markdown_force_one_space_between_words = true
ij_markdown_keep_indents_on_empty_lines = false
ij_markdown_max_lines_around_block_elements = 1
ij_markdown_max_lines_around_header = 1
ij_markdown_max_lines_between_paragraphs = 1
ij_markdown_min_lines_around_block_elements = 1
ij_markdown_min_lines_around_header = 1
ij_markdown_min_lines_between_paragraphs = 1
[{*.properties,spring.handlers,spring.schemas}]
ij_properties_align_group_field_declarations = false
ij_properties_keep_blank_lines = false
ij_properties_key_value_delimiter = equals
ij_properties_spaces_around_key_value_delimiter = false
[{*.yaml,*.yml}]
indent_size = 2
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
ij_yaml_space_before_colon = true
ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true
```
Java is not formatted with Google Java Format. Continue to follow the repository IntelliJ/EditorConfig style for Java changes.
-57
View File
@@ -1,57 +0,0 @@
---
name: Bug report
about: Create a bug report for the plugin
title: ''
labels: investigate
assignees: ''
---
<!-- 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 -->
## Information
<!-- Version of the Server, e.g. Spigot 1.14.1 or Paper 1.19.3-->
* Server Version:
<!-- Version of OldCombatMechanics, e.g. 1.7.2 or dev build #46 -->
<!-- Please don't say 'latest', that is meaningless as new versions are frequently released -->
* OldCombatMechanics version:
<!-- Console log from the server, please paste it within the backticks (i.e. ```) -->
* Server Log File:
<details><summary>Server Log</summary>
``` console
```
</details>
<!-- OldCombatMechanics config.yml, please paste it within the backticks (i.e. ```) -->
* OldCombatMechanics config file:
<details><summary>config.yml</summary>
``` yaml
```
</details>
<!-- If the issue is damage related, please enable debug mode in the config and provide screenshots or log of the output-->
## Problem Description
<!-- A clear and concise description of what the bug is. -->
### To Reproduce
<!-- Please enter what you would do in order for the problem to occur -->
Steps to reproduce the behavior:
1.
2.
3.
### Expected Behaviour
<!-- What do you think should happen when you perform the above steps? -->
### Actual Behaviour
<!-- What does happen when you perform the above steps? -->
<!-- You may add more details here, e.g. screenshots or videos -->
-23
View File
@@ -1,23 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
<!-- Please make sure there isn't already a request for this feature, by searching for open issues with the enhancement tag -->
<!-- Remember that this plugin is about changes in combat mechanics following 1.8 -->
<!-- If your enhancement is about a combat feature that changed between 1.8 and now, please provide some evidence of such change -->
<!-- Please describe the problem you are facing, as a consequence of a lack of features rather than a bug -->
## Problem
<!-- Please enter the solution you think best for the problem -->
## Proposed solution
<!-- Please describe alternative solutions you have considered ->>
## Alternative solutions
<!-- Additional Info: Any other context / screenshots that could be relevant -->
+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)"
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-component-in-tag": false,
"bump-minor-pre-major": true,
"bootstrap-sha": "56dd66a4bf88f252bb063b91835d89a1a1a2e442",
"packages": {
".": {
"package-name": "OldCombatMechanics",
"release-type": "simple",
"extra-files": [
{
"type": "generic",
"path": "build.gradle.kts"
}
],
"changelog-sections": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "docs", "section": "Documentation"},
{"type": "refactor", "section": "Refactoring"},
{"type": "test", "section": "Tests"}
]
}
}
}
+1 -1
View File
@@ -1 +1 @@
{".":"2.1.0"}
{".":"2.5.0"}
+36 -21
View File
@@ -14,19 +14,25 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
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: |
@@ -38,39 +44,48 @@ jobs:
./gradlew clean build
fi
- name: Rename Release Asset
if: ${{ github.event_name == 'release' }}
run: mv ./build/libs/OldCombatMechanics.jar ./build/libs/OldCombatMechanics-${{ github.event.release.tag_name }}.jar
- name: Upload Artifact to GitHub Release
if: ${{ github.event_name == 'release' }}
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
# The 'files' input takes a path to the asset(s) you want to upload.
# We point it to the newly renamed JAR file.
files: ./build/libs/OldCombatMechanics-${{ github.event.release.tag_name }}.jar
# Keep a stable filename for external download links.
files: ./build/libs/OldCombatMechanics.jar
- name: Read game versions from gradle.properties
run: |
GAME_VERSIONS=$(grep ^gameVersions gradle.properties | cut -d'=' -f2-)
echo "GAME_VERSIONS=$GAME_VERSIONS" >> $GITHUB_ENV
- name: Upload to dev.bukkit.org
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: benwoo1110/dbo-upload-action@main
uses: itsmeow/curseforge-upload@v3
with:
api_token: ${{ secrets.DBO_UPLOAD_API_TOKEN }}
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
display_name: OldCombatMechanics ${{ github.event.release.tag_name }}
changelog_type: 'markdown'
release_type: 'release'
display_name: 'OldCombatMechanics ${{ github.event.release.tag_name }}'
game_versions: ${{ env.GAME_VERSIONS }}
# IMPORTANT: Update the file_path to use the renamed JAR
file_path: './build/libs/OldCombatMechanics-${{ github.event.release.tag_name }}.jar'
- name: Publish to Hangar
env:
HANGAR_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
HANGAR_CHANGELOG: ${{ github.event.release.body }}
run: ./gradlew build publishPluginPublicationToHangar --stacktrace
+8 -4
View File
@@ -14,19 +14,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: wrapper
@@ -36,12 +36,16 @@ jobs:
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@v4
uses: actions/upload-artifact@v7
with:
name: OldCombatMechanics
path: build/libs/OldCombatMechanics.jar
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: release-please
uses: googleapis/release-please-action@v4
uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/release-please-config.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
});
+8 -6
View File
@@ -19,11 +19,13 @@ out
builds
target
META-INF
/build/
/gradle/
/.gradle/
build/
.kotlin/
.gradle/
.gradle-user/
.gradle-cache/
.gradle-local/
kls-classpath
kls_database.db
/run/
/bin/
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)
}
}
```
+125
View File
@@ -1,5 +1,130 @@
# 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)
+99 -57
View File
@@ -11,73 +11,117 @@
## by kernitus and Rayzr522
A performant Spigot/Paper plugin to configure combat-related mechanics for Minecraft 1.9 upwards.
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/>
<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>
## 🧰 Modesets
Modesets can be configured for your players to choose from. Each modeset can have any combination of the below features enabled, with examples provided to replicate 1.9 vs 1.8 combat. Players can switch between modesets via command, choosing from the modesets allowed for the world they are in.
- 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).
## ⚙ Configurable Features
Features are grouped in `module`s as listed below, and can be individually configured and disabled. Modules that are fully disabled will have no impact on server performance.
#### ⚔ Combat
- Attack cooldown
- Attack frequency
- Tool damage
- Critical hits
- Player regen
#### 🤺 Armour
- Armour strength
- Armour durability
#### 🛡 Swords & Shields
- Sword blocking
- Shield damage reduction
- Sword sweep
#### 🌬 Knockback
- Player knockback
- Fishing knockback
- Fishing rod velocity
- Projectile knockback
#### 🧙 Gapples & Potions
- Golden apple crafting & effects
- Potion effects & duration
- Chorus fruit
#### ❌ New feature disabling
- Item crafting
- Offhand
- New attack sounds
- Enderpearl cooldown
- Brewing stand refuel
- Burn delay
## 🔌 Plugin Compatibility
Most plugins will work fine with OCM. Some are explicitly supported, including:
- Placeholder API (see [wiki](https://github.com/kernitus/BukkitOldCombatMechanics/wiki/PlaceholderAPI) for details)
## 🤝 Contributions
If you are interested in contributing, please [check this page first](.github/CONTRIBUTING.md).
<hr/>
@@ -85,5 +129,3 @@ If you are interested in contributing, please [check this page first](.github/CO
<a href="https://bstats.org/plugin/bukkit/OldCombatMechanics">
<img src="https://bstats.org/signatures/bukkit/OldCombatMechanics.svg" alt="bStats">
</a>
+822 -78
View File
@@ -5,21 +5,76 @@
*/
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import groovy.json.JsonSlurper
import io.papermc.hangarpublishplugin.model.Platforms
import java.io.ByteArrayOutputStream
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)
val paperVersion: List<String> =
(property("gameVersions") as String)
.split(",")
.map { it.trim() }
plugins {
`java-library`
id("com.github.johnrengelman.shadow") version "8.1.1"
// For ingametesting
//id("io.papermc.paperweight.userdev") version "1.5.10"
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.2"
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
@@ -32,10 +87,14 @@ idea {
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
@@ -44,33 +103,8 @@ repositories {
maven("https://libraries.minecraft.net/")
}
dependencies {
implementation("org.bstats:bstats-bukkit:3.0.2")
// Shaded in by Bukkit
compileOnly("io.netty:netty-all:4.1.106.Final")
// Placeholder API
compileOnly("me.clip:placeholderapi:2.11.5")
// For BSON file serialisation
implementation("org.mongodb:bson:5.0.1")
// Spigot
compileOnly("org.spigotmc:spigot-api:1.21.8-R0.1-SNAPSHOT")
// ProtocolLib
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
// XSeries
implementation("com.github.cryptomorin:XSeries:13.3.3")
//For ingametesting
// Mojang mappings for NMS
/*
compileOnly("com.mojang:authlib:4.0.43")
paperweight.paperDevBundle("1.19.2-R0.1-SNAPSHOT")
// For reflection remapping
implementation("xyz.jpenilla:reflection-remapper:0.1.1")
*/
}
group = "kernitus.plugin.OldCombatMechanics"
version = "2.1.0" // x-release-please-version
version = "2.5.0" // x-release-please-version
description = "OldCombatMechanics"
java {
@@ -82,35 +116,159 @@ java {
}
sourceSets {
main {
java {
exclude("kernitus/plugin/OldCombatMechanics/tester/**")
}
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
}
}
// Substitute ${pluginVersion} in plugin.yml with version defined above
tasks.named<Copy>("processResources") {
inputs.property("pluginVersion", version)
filesMatching("plugin.yml") {
expand("pluginVersion" to version)
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.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")
}
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"))
@@ -119,57 +277,643 @@ tasks.reobfJar {
tasks.assemble {
// For ingametesting
//dependsOn("reobfJar")
// dependsOn("reobfJar")
dependsOn("shadowJar")
}
// Function to execute Git commands
fun executeGitCommand(vararg command: String): String {
val byteOut = ByteArrayOutputStream()
project.exec {
commandLine = listOf("git", *command)
standardOutput = byteOut
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")
}
return byteOut.toString(Charsets.UTF_8.name()).trim()
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)
}
// Function to get the latest commit message
fun latestCommitMessage(): String {
return executeGitCommand("log", "-1", "--pretty=%B")
fun needsLegacyVanillaJar(version: String): Boolean {
val (major, minor, _) = parseMinecraftVersion(version)
return major == 1 && minor <= 12
}
// Function to get the short commit hash
fun getShortCommitHash(): String {
return executeGitCommand("rev-parse", "--short", "HEAD")
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
}
val versionString: String = project.version as String
val isRelease: Boolean = !versionString.contains('-')
val suffixedVersion: String = if (isRelease) {
versionString
} else {
// Append the short commit hash to the version for snapshots
"$versionString+${getShortCommitHash()}"
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
}
// Use the latest commit message for the changelog
val changelogContent: String = latestCommitMessage()
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 {
val isRelease = !project.version.toString().contains('-')
println(if (isRelease) "true" else "false")
println(if (!project.version.toString().contains('-')) "true" else "false")
}
}
hangarPublish {
publications.register("plugin") {
version.set(suffixedVersion)
channel.set(if (isRelease) "Release" else "Snapshot")
version.set(suffixedVersionProvider)
channel.set(isReleaseProvider.map { if (it) "Release" else "Snapshot" })
id.set("OldCombatMechanics")
apiKey.set(System.getenv("HANGAR_API_TOKEN"))
changelog.set(System.getenv("HANGAR_CHANGELOG") ?: changelogContent)
changelog.set(changelogProvider)
platforms {
register(Platforms.PAPER) {
jar.set(tasks.shadowJar.flatMap { it.archiveFile })
+4 -1
View File
@@ -2,4 +2,7 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
gameVersions=1.21, 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
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.
+4 -1
View File
@@ -1,6 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
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
+18 -15
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# 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.
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# 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/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# 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
@@ -111,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -130,10 +132,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
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.
@@ -141,7 +146,7 @@ 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=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -149,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -166,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -198,16 +202,15 @@ 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 $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# 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" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+82 -92
View File
@@ -1,92 +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
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@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%
+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
-2
View File
@@ -3,6 +3,4 @@
* 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)
@@ -0,0 +1,428 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.StringSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.block.BlockFace
import org.bukkit.entity.Entity
import org.bukkit.entity.EntityType
import org.bukkit.entity.Item
import org.bukkit.entity.Player
import org.bukkit.event.block.Action
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerInteractAtEntityEvent
import org.bukkit.event.player.PlayerInteractEntityEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.util.Vector
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class SwordBlockingIntegrationTest :
StringSpec({
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
val module =
ModuleLoader
.getModules()
.filterIsInstance<ModuleSwordBlocking>()
.firstOrNull() ?: error("ModuleSwordBlocking not registered")
extension(MainThreadDispatcherExtension(plugin))
lateinit var player: Player
lateinit var fakePlayer: FakePlayer
fun <T> runSync(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
plugin,
Callable {
action()
},
).get()
}
fun preparePlayer() {
println("Preparing player")
val world = Bukkit.getServer().getWorld("world")
val location = Location(world, 0.0, 100.0, 0.0)
fakePlayer = FakePlayer(plugin)
fakePlayer.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
}
beforeSpec {
Bukkit.getScheduler().runTask(
plugin,
Runnable {
plugin.logger.info("Running before all")
preparePlayer()
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0 // remove spawn invulnerability
player.isInvulnerable = false
},
)
}
fun rightClickWithMainHand() =
runSync {
val event =
PlayerInteractEvent(
player,
Action.RIGHT_CLICK_AIR,
player.inventory.itemInMainHand,
null,
BlockFace.SELF,
EquipmentSlot.HAND,
)
Bukkit.getPluginManager().callEvent(event)
}
fun rightClickEntity(
target: Entity,
hand: EquipmentSlot,
) = runSync {
Bukkit.getPluginManager().callEvent(PlayerInteractEntityEvent(player, target, hand))
}
fun rightClickEntityAt(
target: Entity,
hand: EquipmentSlot,
) = runSync {
Bukkit.getPluginManager().callEvent(PlayerInteractAtEntityEvent(player, target, Vector(0.0, 1.0, 0.0), hand))
}
fun spawnEntityTarget(): Entity =
runSync {
player.world.spawnEntity(player.location.clone().add(1.0, 0.0, 0.0), EntityType.VILLAGER)
}
fun forceRestoreViaHotbarChange() =
runSync {
val previous = player.inventory.heldItemSlot
val next = (previous + 1) % 9
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, previous, next))
player.inventory.heldItemSlot = previous
}
fun paperConsumablePathAvailable(): Boolean =
try {
Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
val supportedField = ModuleSwordBlocking::class.java.getDeclaredField("paperSupported")
supportedField.isAccessible = true
val adapterField = ModuleSwordBlocking::class.java.getDeclaredField("paperAdapter")
adapterField.isAccessible = true
supportedField.getBoolean(module) && adapterField.get(module) != null
} catch (_: Throwable) {
false
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
suspend fun TestScope.withPaperAnimationEnabled(
enabled: Boolean,
block: suspend TestScope.() -> Unit,
) {
val original = runSync { ocm.config.get("sword-blocking.paper-animation") }
runSync {
ocm.config.set("sword-blocking.paper-animation", enabled)
ocm.saveConfig()
Config.reload()
}
try {
block()
} finally {
runSync {
ocm.config.set("sword-blocking.paper-animation", original)
ocm.saveConfig()
Config.reload()
}
}
}
suspend fun TestScope.withUsePermission(
required: Boolean,
block: suspend TestScope.() -> Unit,
) {
val original = runSync { ocm.config.getBoolean("sword-blocking.use-permission") }
runSync {
ocm.config.set("sword-blocking.use-permission", required)
module.reload()
ModuleLoader.toggleModules()
}
try {
block()
} finally {
runSync {
ocm.config.set("sword-blocking.use-permission", original)
module.reload()
ModuleLoader.toggleModules()
}
}
}
beforeTest {
runSync {
player.inventory.clear()
player.noDamageTicks = 0
player.maximumNoDamageTicks = 20
player.isInvulnerable = false
}
}
afterTest {
forceRestoreViaHotbarChange()
runSync { player.inventory.clear() }
}
afterSpec {
plugin.logger.info("Running after all")
Bukkit.getScheduler().runTask(
plugin,
Runnable {
fakePlayer.removePlayer()
},
)
}
"adds blocking when right-clicking with a sword (shield on legacy, consumable on Paper)" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
// Legacy path: module injects a shield (actual "blocking" state is client-driven).
// Paper path: offhand remains intact and a consumable-based use animation can surface as "hand raised".
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
// Legacy path injects shield; paper path keeps offhand intact
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
}
"paper-animation config false forces legacy shield fallback on Paper" {
if (!paperConsumablePathAvailable()) {
println("Skipping: Paper consumable component path unavailable")
} else {
withPaperAnimationEnabled(enabled = false) {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
}
}
}
"does not start blocking without a sword in the main hand" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.STICK))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
}
"starts blocking on main-hand entity right-click (shield on legacy, consumable on Paper)" {
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickEntity(target, EquipmentSlot.HAND)
delayTicks(1)
runSync {
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
} finally {
runSync { target.remove() }
}
}
"does not start blocking on offhand entity right-click" {
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickEntity(target, EquipmentSlot.OFF_HAND)
delayTicks(1)
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
} finally {
runSync { target.remove() }
}
}
"entity interact plus interact-at should not duplicate side effects" {
val originalOffhand = ItemStack(Material.APPLE)
val target = spawnEntityTarget()
try {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(originalOffhand.clone())
}
rightClickEntity(target, EquipmentSlot.HAND)
rightClickEntityAt(target, EquipmentSlot.HAND)
delayTicks(1)
runSync {
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
forceRestoreViaHotbarChange()
}
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
}
} finally {
runSync { target.remove() }
}
}
"restores the previous offhand item after a hotbar change (or leaves untouched on Paper path)" {
val originalOffhand = ItemStack(Material.APPLE)
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(originalOffhand.clone())
}
rightClickWithMainHand()
runSync {
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
forceRestoreViaHotbarChange()
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
} else {
// Paper path: offhand never changed
player.inventory.itemInOffHand.type shouldBe originalOffhand.type
}
}
}
"cancels dropping the temporary shield and restores the stored item (legacy path only)" {
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
val dropped: Item = runSync { player.world.dropItem(player.location, ItemStack(Material.SHIELD)) }
runSync {
Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, dropped))
}
runSync {
if (player.inventory.itemInOffHand.type == Material.SHIELD) {
player.inventory.itemInOffHand.type shouldBe Material.AIR
player.isBlocking shouldBe false
} else {
// Paper path: no injected shield; ensure we did not cancel normal state
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
}
runSync { dropped.remove() }
}
"respects permission requirement when enabled" {
withUsePermission(required = true) {
runSync {
player.isOp = false
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
player.inventory.setItemInOffHand(ItemStack(Material.AIR))
}
rightClickWithMainHand()
delayTicks(1)
runSync {
player.isBlocking shouldBe false
player.inventory.itemInOffHand.type shouldBe Material.AIR
}
runSync { player.addAttachment(plugin, "oldcombatmechanics.swordblock", true) }
rightClickWithMainHand()
delayTicks(1)
runSync {
// Legacy path injects a shield and sets isBlocking; Paper path keeps offhand intact and uses a
// consumable-based use animation which can surface as "hand raised".
(player.inventory.itemInOffHand.type == Material.SHIELD || player.isBlocking || player.isHandRaised) shouldBe true
setOf(Material.SHIELD, Material.AIR).contains(player.inventory.itemInOffHand.type) shouldBe true
}
}
}
"does not replace an existing real shield in offhand" {
val namedShield =
ItemStack(Material.SHIELD).apply {
val meta = itemMeta
meta?.setDisplayName("Real Shield")
itemMeta = meta
}
runSync {
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
player.inventory.setItemInOffHand(namedShield)
}
rightClickWithMainHand()
runSync {
val meta = player.inventory.itemInOffHand.itemMeta
meta?.displayName shouldBe "Real Shield"
player.inventory.itemInOffHand.type shouldBe Material.SHIELD
}
}
})
@@ -0,0 +1,150 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweep
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class SwordSweepIntegrationTest : FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val module = ModuleLoader.getModules()
.filterIsInstance<ModuleSwordSweep>()
.firstOrNull() ?: error("ModuleSwordSweep not registered")
lateinit var attacker: Player
lateinit var victim: Player
lateinit var fakeAttacker: FakePlayer
lateinit var fakeVictim: FakePlayer
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
action()
null
}).get()
}
}
fun setModeset(player: Player, modeset: String) {
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
}
extensions(MainThreadDispatcherExtension(testPlugin))
beforeSpec {
runSync {
val world = Bukkit.getServer().getWorld("world")
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
val victimLocation = Location(world, 1.0, 100.0, 0.0)
fakeAttacker = FakePlayer(testPlugin)
fakeVictim = FakePlayer(testPlugin)
fakeAttacker.spawn(attackerLocation)
fakeVictim.spawn(victimLocation)
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
setModeset(attacker, "old")
setModeset(victim, "old")
module.reload()
}
}
afterSpec {
runSync {
fakeAttacker.removePlayer()
fakeVictim.removePlayer()
}
}
beforeTest {
runSync {
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
setModeset(attacker, "old")
setModeset(victim, "old")
module.reload()
}
}
context("Sweep attack cancellation") {
test("sweep attack is cancelled when enabled") {
val sweepCause = EntityDamageEvent.DamageCause.values()
.firstOrNull { it.name == "ENTITY_SWEEP_ATTACK" }
if (sweepCause != null) {
val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)
Bukkit.getPluginManager().callEvent(event)
event.isCancelled shouldBe true
} else {
// Legacy (1.9): simulate sweep detection fallback (no dedicated cause)
val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)
?: 1.0f).toDouble()
val sweepDamage = 1.0 // matches ModuleSwordSweep fallback for level 0
// First, register the attacker location so the module can recognise the next hit as sweep
val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)
module.onEntityDamaged(priming)
val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)
module.onEntityDamaged(sweepEvent)
sweepEvent.isCancelled shouldBe true
}
}
test("non-sweep attack is not cancelled") {
val event = EntityDamageByEntityEvent(
attacker,
victim,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
1.0
)
Bukkit.getPluginManager().callEvent(event)
event.isCancelled shouldBe false
}
test("disabled module does not cancel sweep") {
setModeset(attacker, "new")
val sweepCause = EntityDamageEvent.DamageCause.values()
.firstOrNull { it.name == "ENTITY_SWEEP_ATTACK" }
if (sweepCause != null) {
val event = EntityDamageByEntityEvent(attacker, victim, sweepCause, 1.0)
module.onEntityDamaged(event)
event.isCancelled shouldBe false
} else {
val baseDamage = (NewWeaponDamage.getDamageOrNull(attacker.inventory.itemInMainHand.type)
?: 1.0f).toDouble()
val sweepDamage = 1.0
val priming = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, baseDamage + 1)
module.onEntityDamaged(priming)
val sweepEvent = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, sweepDamage)
module.onEntityDamaged(sweepEvent)
sweepEvent.isCancelled shouldBe false
}
}
}
})
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
class Tally {
var passed: Int = 0
private set
var failed: Int = 0
private set
fun passed() {
passed++
}
fun failed() {
failed++
}
val total: Int
get() = passed + failed
}
@@ -0,0 +1,43 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.logging.Level
object TestResultWriter {
@JvmStatic
fun writeAndShutdown(plugin: JavaPlugin, success: Boolean, error: Throwable? = null) {
try {
val resultFile = File(plugin.dataFolder, "test-results.txt")
resultFile.parentFile.mkdirs()
resultFile.writeText(if (success) "PASS" else "FAIL")
plugin.logger.info("Test result written to ${resultFile.absolutePath}")
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "Failed to write test results file.", e)
}
if (error != null) {
plugin.logger.log(Level.SEVERE, "Integration tests failed.", error)
}
Bukkit.shutdown()
}
@JvmStatic
fun writeFailureSummary(plugin: JavaPlugin, lines: List<String>) {
try {
val file = File(plugin.dataFolder, "test-failures.txt")
file.parentFile.mkdirs()
file.writeText(lines.joinToString(separator = "\n", postfix = if (lines.isEmpty()) "" else "\n"))
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "Failed to write test failures file.", e)
}
}
}
@@ -0,0 +1,62 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import kernitus.plugin.OldCombatMechanics.utilities.Messenger.send
import org.bukkit.command.CommandSender
import org.bukkit.entity.LivingEntity
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
object TesterUtils {
/**
* Checks whether the two values are equal, prints the result and updates the tally
*
* @param a The expected value
* @param b The actual value
* @param tally The tally to update the result of the test with
* @param testName The name of the test being run
* @param senders The command senders to message with the result of the test
*/
fun assertEquals(a: Float, b: Float, tally: Tally, testName: String, vararg senders: CommandSender) {
// Due to cooldown effects, numbers can be very close (e.g. 1.0000000149011612 == 1.0)
// These are equivalent when using floats, which is what the server is using anyway
if (a == b) {
tally.passed()
for (sender in senders) send(
sender,
"&aPASSED &f$testName [E: $a / A: $b]"
)
} else {
tally.failed()
for (sender in senders) send(
sender,
"&cFAILED &f$testName [E: $a / A: $b]"
)
}
}
/**
* Cross-version accessor for a specific potion effect. Pre-1.12 servers lack
* LivingEntity#getPotionEffect, so we fall back to scanning active effects.
*/
fun LivingEntity.getPotionEffectCompat(type: PotionEffectType): PotionEffect? {
// Prefer reflection to avoid linkage errors on legacy servers.
val method = javaClass.methods.firstOrNull { m ->
m.name == "getPotionEffect" &&
m.parameterTypes.size == 1 &&
m.parameterTypes[0] == PotionEffectType::class.java
}
return runCatching { method?.invoke(this, type) as PotionEffect? }.getOrNull()
?: activePotionEffects.firstOrNull { it.type == type }
}
}
@@ -0,0 +1,441 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.test.TestScope
import io.kotest.matchers.doubles.plusOrMinus
import io.kotest.matchers.shouldBe
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.ChatColor
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerItemHeldEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerSwapHandItemsEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.Callable
@OptIn(ExperimentalKotest::class)
class ToolDamageTooltipIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
fun <T> runSyncAndGet(action: () -> T): T =
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
val lorePrefix = "OCM Damage:"
fun stripColour(line: String): String = ChatColor.stripColor(line) ?: line
fun findOcmLines(item: ItemStack): List<String> {
val lore = item.itemMeta?.lore ?: emptyList()
return lore.filter { stripColour(it).startsWith(lorePrefix) }
}
fun parseFirstDamage(item: ItemStack): Double? {
val line = findOcmLines(item).firstOrNull() ?: return null
val stripped = stripColour(line)
val match = Regex("(-?\\d+(?:\\.\\d+)?)").find(stripped) ?: return null
return match.value.toDoubleOrNull()
}
fun setLore(
item: ItemStack,
lines: List<String>?,
) {
val meta = item.itemMeta ?: return
meta.lore = lines
item.itemMeta = meta
}
data class SpawnedPlayer(
val fake: FakePlayer,
val player: Player,
)
fun spawnFake(location: Location): SpawnedPlayer {
lateinit var fake: FakePlayer
lateinit var player: Player
runSync {
fake = FakePlayer(testPlugin)
fake.spawn(location)
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
player.inventory.clear()
player.isInvulnerable = false
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val data =
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.getPlayerData(player.uniqueId)
data.setModesetForWorld(player.world.uid, "old")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.setPlayerData(player.uniqueId, data)
}
return SpawnedPlayer(fake, player)
}
fun cleanup(vararg players: SpawnedPlayer) {
runSync { players.forEach { it.fake.removePlayer() } }
}
suspend fun TestScope.withConfig(
weaponMaterialKey: String,
weaponDamage: Double,
block: suspend TestScope.() -> Unit,
) {
val disabledModules = ocm.config.getStringList("disabled_modules")
val modesetsSection = ocm.config.getConfigurationSection("modesets") ?: error("Missing 'modesets' section in config")
val modesetSnapshot =
modesetsSection.getKeys(false).associateWith { key ->
ocm.config.getStringList("modesets.$key")
}
val damagesSnapshot =
ocm.config
.getConfigurationSection("old-tool-damage.damages")
?.getValues(false)
?: emptyMap<String, Any?>()
val tooltipEnabledSnapshot = ocm.config.get("old-tool-damage.tooltip.enabled")
val tooltipPrefixSnapshot = ocm.config.get("old-tool-damage.tooltip.prefix")
fun reloadAll() {
ocm.saveConfig()
Config.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
}
try {
ocm.config.set("old-tool-damage.damages.$weaponMaterialKey", weaponDamage)
ocm.config.set("old-tool-damage.tooltip.enabled", true)
ocm.config.set("old-tool-damage.tooltip.prefix", lorePrefix)
ocm.config.set("disabled_modules", disabledModules.filterNot { it == "old-tool-damage" })
val oldModeset = ocm.config.getStringList("modesets.old").toMutableList()
if (!oldModeset.contains("old-tool-damage")) {
oldModeset.add("old-tool-damage")
}
ocm.config.set("modesets.old", oldModeset)
reloadAll()
block()
} finally {
ocm.config.set("disabled_modules", disabledModules)
modesetSnapshot.forEach { (key, list) -> ocm.config.set("modesets.$key", list) }
ocm.config.set("old-tool-damage.damages", null)
damagesSnapshot.forEach { (k, v) -> ocm.config.set("old-tool-damage.damages.$k", v) }
ocm.config.set("old-tool-damage.tooltip.enabled", tooltipEnabledSnapshot)
ocm.config.set("old-tool-damage.tooltip.prefix", tooltipPrefixSnapshot)
reloadAll()
}
}
fun fireJoin(player: Player) {
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, "test"))
}
fun switchHotbar(
player: Player,
from: Int,
to: Int,
) {
player.inventory.heldItemSlot = to
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))
}
test("adds a tooltip lore line for configured vanilla weapon damage") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItem(0, sword)
p.player.inventory.setItem(1, ItemStack(Material.STICK))
p.player.inventory.heldItemSlot = 1
}
runSync { switchHotbar(p.player, from = 1, to = 0) }
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val loreLines = findOcmLines(held)
loreLines.size shouldBe 1
parseFirstDamage(held) shouldBe (7.0 plusOrMinus 0.01)
cleanup(p)
}
}
test("does not duplicate the tooltip lore line when applied repeatedly") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItemInMainHand(sword)
}
runSync {
fireJoin(p.player)
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val loreLines = findOcmLines(held)
loreLines.size shouldBe 1
cleanup(p)
}
}
test("preserves existing lore when adding the tooltip line") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
setLore(sword, listOf("OtherPlugin: Example"))
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val lore = held.itemMeta?.lore ?: emptyList()
lore.any { stripColour(it) == "OtherPlugin: Example" } shouldBe true
findOcmLines(held).size shouldBe 1
cleanup(p)
}
}
test("updates tooltip damage after config reload") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
}
runSyncAndGet { parseFirstDamage(p.player.inventory.itemInMainHand) } shouldBe (7.0 plusOrMinus 0.01)
runSync {
ocm.config.set("old-tool-damage.damages.DIAMOND_SWORD", 9.0)
ocm.saveConfig()
Config.reload()
WeaponDamages.initialise(ocm)
ModuleLoader.toggleModules()
fireJoin(p.player)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
findOcmLines(held).size shouldBe 1
parseFirstDamage(held) shouldBe (9.0 plusOrMinus 0.01)
cleanup(p)
}
}
test("cleans the tooltip lore line when the module is disabled") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
runSync {
p.player.inventory.setItem(0, sword)
p.player.inventory.setItem(1, ItemStack(Material.STICK))
p.player.inventory.heldItemSlot = 1
switchHotbar(p.player, from = 1, to = 0)
}
runSyncAndGet {
val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)
findOcmLines(slot0).size
} shouldBe 1
runSync {
val data =
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.getPlayerData(p.player.uniqueId)
data.setModesetForWorld(p.player.world.uid, "new")
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
.setPlayerData(p.player.uniqueId, data)
switchHotbar(p.player, from = 0, to = 1) // should clean the old hand
}
delayTicks(1)
runSyncAndGet {
val slot0 = p.player.inventory.getItem(0) ?: ItemStack(Material.AIR)
findOcmLines(slot0).size
} shouldBe 0
cleanup(p)
}
}
test("does not add a tooltip lore line for non-weapons") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val stick = ItemStack(Material.STICK)
runSync {
p.player.inventory.setItemInMainHand(stick)
fireJoin(p.player)
}
findOcmLines(stick).size shouldBe 0
cleanup(p)
}
}
test("swap hand items applies tooltip to new main hand and keeps offhand clean") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
val stick = ItemStack(Material.STICK)
runSync {
p.player.inventory.setItemInMainHand(stick)
p.player.inventory.setItemInOffHand(sword)
val swap = PlayerSwapHandItemsEvent(p.player, stick, sword)
Bukkit.getPluginManager().callEvent(swap)
}
findOcmLines(sword).size shouldBe 1
findOcmLines(stick).size shouldBe 0
cleanup(p)
}
}
test("swap hand finalisation keeps tooltip only on new main-hand weapon") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
runSync {
p.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
p.player.inventory.setItemInOffHand(ItemStack(Material.DIAMOND_SWORD))
val swap =
PlayerSwapHandItemsEvent(
p.player,
p.player.inventory.itemInMainHand,
p.player.inventory.itemInOffHand,
)
Bukkit.getPluginManager().callEvent(swap)
val newMainHand = swap.offHandItem?.clone() ?: ItemStack(Material.AIR)
val newOffHand = swap.mainHandItem?.clone() ?: ItemStack(Material.AIR)
p.player.inventory.setItemInMainHand(newMainHand)
p.player.inventory.setItemInOffHand(newOffHand)
}
val mainHand =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val offHand =
runSyncAndGet {
p.player.inventory.itemInOffHand
.clone()
}
mainHand.type shouldBe Material.DIAMOND_SWORD
offHand.type shouldBe Material.STICK
findOcmLines(mainHand).size shouldBe 1
findOcmLines(offHand).size shouldBe 0
cleanup(p)
}
}
test("plays nicely with a lore-rewriting plugin (other lore preserved, no duplication)") {
withConfig(weaponMaterialKey = "DIAMOND_SWORD", weaponDamage = 7.0) {
val world = checkNotNull(Bukkit.getWorld("world"))
val p = spawnFake(Location(world, 0.0, 100.0, 0.0))
val sword = ItemStack(Material.DIAMOND_SWORD)
val otherPlugin =
object : Listener {
@EventHandler(priority = EventPriority.LOWEST)
fun onJoin(event: PlayerJoinEvent) {
if (event.player != p.player) return
val item = event.player.inventory.itemInMainHand
if (item.type != Material.DIAMOND_SWORD) return
setLore(item, listOf("OtherPlugin: Rewritten"))
}
@EventHandler(priority = EventPriority.LOWEST)
fun onHeld(event: PlayerItemHeldEvent) {
if (event.player != p.player) return
val item = event.player.inventory.itemInMainHand
if (item.type != Material.DIAMOND_SWORD) return
setLore(item, listOf("OtherPlugin: Rewritten"))
}
}
runSync {
Bukkit.getPluginManager().registerEvents(otherPlugin, testPlugin)
p.player.inventory.setItemInMainHand(sword)
fireJoin(p.player)
fireJoin(p.player)
HandlerList.unregisterAll(otherPlugin)
}
val held =
runSyncAndGet {
p.player.inventory.itemInMainHand
.clone()
}
val lore = held.itemMeta?.lore ?: emptyList()
lore.any { stripColour(it) == "OtherPlugin: Rewritten" } shouldBe true
findOcmLines(held).size shouldBe 1
cleanup(p)
}
}
})
@@ -0,0 +1,611 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.ExperimentalKotest
import io.kotest.core.spec.style.FunSpec
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kotlinx.coroutines.delay
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.HandlerList
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.player.PlayerItemDamageEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.concurrent.Callable
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalKotest::class)
class WeaponDurabilityIntegrationTest :
FunSpec({
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
extensions(MainThreadDispatcherExtension(testPlugin))
fun runSync(action: () -> Unit) {
if (Bukkit.isPrimaryThread()) {
action()
} else {
Bukkit
.getScheduler()
.callSyncMethod(
testPlugin,
Callable {
action()
null
},
).get()
}
}
suspend fun delayTicks(ticks: Long) {
delay(ticks * 50L)
}
suspend fun waitUntilTicks(
maxTicks: Long,
condition: () -> Boolean,
): Boolean {
repeat(maxTicks.toInt()) {
if (condition()) return true
delayTicks(1)
}
return condition()
}
fun scoreAttackMethodLocal(method: java.lang.reflect.Method): Int {
var score = 0
val name = method.name
val param = method.parameterTypes[0]
val declaring = method.declaringClass.simpleName
if (name == "attack") score += 100
if (name == "a") score += 80
if (param.simpleName == "Entity") score += 40
if (param.simpleName.contains("Entity")) score += 10
if (method.returnType == Void.TYPE) score += 10
if (method.returnType == java.lang.Boolean.TYPE) score += 8
if (declaring.contains("EntityHuman")) score += 25
if (declaring.contains("EntityPlayer")) score += 20
return score
}
fun methodSignatureLocal(method: java.lang.reflect.Method): String {
val params = method.parameterTypes.joinToString(",") { it.name }
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
}
fun collectAllMethods(start: Class<*>): List<java.lang.reflect.Method> {
val methods = LinkedHashMap<String, java.lang.reflect.Method>()
var current: Class<*>? = start
while (current != null) {
current.declaredMethods.forEach { method ->
methods.putIfAbsent(methodSignatureLocal(method), method)
}
current = current.superclass
}
start.methods.forEach { method ->
methods.putIfAbsent(methodSignatureLocal(method), method)
}
return methods.values.toList()
}
fun attackNms(
attacker: Player,
target: LivingEntity,
) {
runCatching {
attacker.attack(target)
return
}
val attackerHandle =
attacker.javaClass.methods
.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
}?.invoke(attacker) ?: error("Failed to resolve CraftPlayer#getHandle for attacker")
val targetHandle =
target.javaClass.methods
.firstOrNull { method ->
method.name == "getHandle" && method.parameterCount == 0
}?.invoke(target) ?: error("Failed to resolve CraftPlayer#getHandle for target")
val attackerHandleClass = attackerHandle.javaClass
val targetHandleClass = targetHandle.javaClass
runCatching {
val managerField =
attackerHandleClass.declaredFields.firstOrNull { field ->
field.type.simpleName.contains("GameMode") || field.type.simpleName.contains("InteractManager")
}
if (managerField != null) {
managerField.isAccessible = true
val manager = managerField.get(attackerHandle) ?: return@runCatching
val attackMethod =
manager.javaClass.methods.firstOrNull { method ->
(method.name == "attack" || method.name == "a") &&
method.parameterCount == 1 &&
method.parameterTypes[0].isAssignableFrom(targetHandleClass)
}
if (attackMethod != null) {
attackMethod.isAccessible = true
attackMethod.invoke(manager, targetHandle)
return
}
}
}
val candidates =
listOfNotNull(
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(
attackerHandleClass,
"attack",
targetHandleClass,
),
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector.getMethodAssignable(
attackerHandleClass,
"a",
targetHandleClass,
),
).ifEmpty {
collectAllMethods(attackerHandleClass)
.asSequence()
.filter { it.parameterCount == 1 }
.filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
.map { method -> method to scoreAttackMethodLocal(method) }
.sortedByDescending { it.second }
.map { it.first }
.toList()
}
candidates.forEach { it.isAccessible = true }
for (method in candidates) {
try {
val result = method.invoke(attackerHandle, targetHandle)
if (result is Boolean && !result) continue
return
} catch (ignored: Exception) {
// try next
}
}
error("Failed to invoke NMS attack for FakePlayer attacker=${attackerHandleClass.name}")
}
fun resolveDebugFile(): File {
val versionTag = Bukkit.getBukkitVersion().replace(Regex("[^A-Za-z0-9_.-]"), "_")
val runDir = File(System.getProperty("user.dir"))
val repoRoot = runDir.parentFile?.parentFile ?: runDir
return File(repoRoot, "build/weapon-durability-debug-$versionTag.txt")
}
fun appendDebug(line: String) {
val file = resolveDebugFile()
file.parentFile?.mkdirs()
file.appendText(line + "\n")
}
fun describeNmsState(
attacker: Player,
victim: LivingEntity,
): String {
return runCatching {
val attackerHandle =
attacker.javaClass.methods
.firstOrNull { it.name == "getHandle" && it.parameterCount == 0 }
?.invoke(attacker) ?: return@runCatching "noAttackerHandle"
val victimHandle =
victim.javaClass.methods
.firstOrNull { it.name == "getHandle" && it.parameterCount == 0 }
?.invoke(victim) ?: return@runCatching "noVictimHandle"
fun flag(
handle: Any,
name: String,
): String? {
val method = handle.javaClass.methods.firstOrNull { it.name == name && it.parameterCount == 0 }
val value = method?.invoke(handle)
return value?.toString()
}
val attackerAlive = flag(attackerHandle, "isAlive")
val victimAlive = flag(victimHandle, "isAlive")
val victimRemoved = flag(victimHandle, "isRemoved")
"attackerAlive=$attackerAlive victimAlive=$victimAlive victimRemoved=$victimRemoved"
}.getOrElse { "nmsErr=${it::class.java.simpleName}" }
}
fun setItemDamage(
item: ItemStack,
damage: Int,
) {
val meta = item.itemMeta
if (meta != null) {
try {
val damageableClass = Class.forName("org.bukkit.inventory.meta.Damageable")
if (damageableClass.isInstance(meta)) {
val setDamage = damageableClass.getMethod("setDamage", Int::class.javaPrimitiveType)
setDamage.invoke(meta, damage)
item.itemMeta = meta
return
}
} catch (ignored: ClassNotFoundException) {
// Legacy server, fall back to durability.
}
}
@Suppress("DEPRECATION")
item.durability = damage.toShort()
}
fun getItemDamage(item: ItemStack): Int {
val meta = item.itemMeta
if (meta != null) {
try {
val damageableClass = Class.forName("org.bukkit.inventory.meta.Damageable")
if (damageableClass.isInstance(meta)) {
val getDamage = damageableClass.getMethod("getDamage")
return (getDamage.invoke(meta) as Number).toInt()
}
} catch (ignored: ClassNotFoundException) {
// Legacy server, fall back to durability.
}
}
@Suppress("DEPRECATION")
return item.durability.toInt()
}
fun setOldModeset(player: Player) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, "old")
setPlayerData(player.uniqueId, playerData)
}
suspend fun withAttackerAndVictim(block: suspend (attacker: Player, victim: LivingEntity) -> Unit) {
lateinit var attacker: Player
lateinit var victim: LivingEntity
val attackerFake = FakePlayer(testPlugin)
runSync {
val world = checkNotNull(Bukkit.getWorld("world"))
attackerFake.spawn(Location(world, 0.0, 100.0, 0.0))
val zombie = world.spawn(Location(world, 1.2, 100.0, 0.0), org.bukkit.entity.Zombie::class.java)
attacker = checkNotNull(Bukkit.getPlayer(attackerFake.uuid))
victim = zombie
setOldModeset(attacker)
attacker.inventory.clear()
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
attacker.isInvulnerable = false
victim.isInvulnerable = false
victim.health = victim.maxHealth
victim.noDamageTicks = 0
}
try {
repeat(40) {
if (attacker.isOnline && attacker.isValid && victim.isValid && !victim.isDead) {
if (attacker.world.players.contains(attacker) && attacker.world.entities.any { it.uniqueId == victim.uniqueId }) {
return@repeat
}
}
delayTicks(1)
}
block(attacker, victim)
} finally {
runSync {
attackerFake.removePlayer()
if (victim.isValid) victim.remove()
}
}
}
test("weapon durability only changes with successful hits during invulnerability") {
withAttackerAndVictim { attacker, victim ->
appendDebug("invuln:start")
try {
val weapon = ItemStack(Material.IRON_SWORD)
setItemDamage(weapon, 0)
runSync {
attacker.inventory.setItemInMainHand(weapon)
victim.maximumNoDamageTicks = 100
victim.noDamageTicks = 0
attacker.gameMode = org.bukkit.GameMode.SURVIVAL
val direction = victim.location.toVector().subtract(attacker.location.toVector())
attacker.teleport(attacker.location.setDirection(direction))
}
delayTicks(5)
appendDebug(
"invuln:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} " +
"victimDead=${victim.isDead} " +
"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} " +
"worldPvp=${victim.world.pvp} " +
"nms=${describeNmsState(attacker, victim)}",
)
val hitCount = AtomicInteger(0)
val totalHitCount = AtomicInteger(0)
val cancelledHitCount = AtomicInteger(0)
val victimEventCount = AtomicInteger(0)
val anyDamageEventCount = AtomicInteger(0)
val allDamageEventCount = AtomicInteger(0)
val itemDamageCount = AtomicInteger(0)
val listener =
object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId) {
victimEventCount.incrementAndGet()
if (event.damager == attacker) {
totalHitCount.incrementAndGet()
if (event.isCancelled) {
cancelledHitCount.incrementAndGet()
} else {
hitCount.incrementAndGet()
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onAnyDamage(event: EntityDamageEvent) {
allDamageEventCount.incrementAndGet()
if (event.entity.uniqueId == victim.uniqueId) {
anyDamageEventCount.incrementAndGet()
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onItemDamage(event: PlayerItemDamageEvent) {
if (event.player == attacker && event.item.type == Material.IRON_SWORD) {
itemDamageCount.addAndGet(event.damage)
}
}
}
runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }
try {
runSync {
Bukkit.getPluginManager().callEvent(
EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),
)
}
delayTicks(1)
appendDebug("invuln:afterManualEvent allDamageEvents=${allDamageEventCount.get()}")
runSync { attackNms(attacker, victim) }
delayTicks(1)
repeat(10) {
runSync { attackNms(attacker, victim) }
delayTicks(1)
}
delayTicks(2)
} finally {
runSync { HandlerList.unregisterAll(listener) }
}
val hits = hitCount.get()
val totalHits = totalHitCount.get()
val cancelledHits = cancelledHitCount.get()
val damageEvents = itemDamageCount.get()
val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)
if (totalHits == 0) {
val beforeHealth = victim.health
runSync { victim.damage(1.0, attacker) }
delayTicks(1)
appendDebug(
"invuln:afterDamage totalHits=${totalHitCount.get()} " +
"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}",
)
}
appendDebug(
"invuln:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits " +
"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} " +
"allDamageEvents=${allDamageEventCount.get()} " +
"itemDamageEvents=$damageEvents itemDamage=$actualDamage",
)
if (hits <= 0) {
// Retry a few swings in case legacy fake player validity lagged.
repeat(5) {
runSync {
victim.noDamageTicks = 0
attackNms(attacker, victim)
}
delayTicks(1)
}
}
val finalHits = hitCount.get()
val finalDamageEvents = itemDamageCount.get()
val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)
val isModern =
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
.versionIsNewerOrEqualTo(1, 12, 0)
if (!isModern) {
// Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.
return@withAttackerAndVictim
}
if (finalHits <= 0) {
error("Expected at least one successful hit, got $finalHits")
}
if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {
error(
"Durability changed per click, not per hit: hits=$finalHits " +
"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage",
)
}
} catch (e: Throwable) {
appendDebug("invuln:error=${e::class.java.simpleName}: ${e.message}")
throw e
}
}
}
test("weapon durability increments on hits after invulnerability expires") {
withAttackerAndVictim { attacker, victim ->
appendDebug("expire:start")
try {
val weapon = ItemStack(Material.IRON_SWORD)
setItemDamage(weapon, 0)
runSync {
attacker.inventory.setItemInMainHand(weapon)
victim.maximumNoDamageTicks = 10
victim.noDamageTicks = 0
attacker.gameMode = org.bukkit.GameMode.SURVIVAL
val direction = victim.location.toVector().subtract(attacker.location.toVector())
attacker.teleport(attacker.location.setDirection(direction))
}
delayTicks(5)
appendDebug(
"expire:state attackerValid=${attacker.isValid} victimValid=${victim.isValid} " +
"victimDead=${victim.isDead} " +
"victimInWorld=${victim.world.entities.any { it.uniqueId == victim.uniqueId }} " +
"worldPvp=${victim.world.pvp} " +
"nms=${describeNmsState(attacker, victim)}",
)
val hitCount = AtomicInteger(0)
val totalHitCount = AtomicInteger(0)
val cancelledHitCount = AtomicInteger(0)
val victimEventCount = AtomicInteger(0)
val anyDamageEventCount = AtomicInteger(0)
val allDamageEventCount = AtomicInteger(0)
val itemDamageCount = AtomicInteger(0)
val listener =
object : Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onHit(event: EntityDamageByEntityEvent) {
if (event.entity.uniqueId == victim.uniqueId) {
victimEventCount.incrementAndGet()
if (event.damager == attacker) {
totalHitCount.incrementAndGet()
if (event.isCancelled) {
cancelledHitCount.incrementAndGet()
} else {
hitCount.incrementAndGet()
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onAnyDamage(event: EntityDamageEvent) {
allDamageEventCount.incrementAndGet()
if (event.entity.uniqueId == victim.uniqueId) {
anyDamageEventCount.incrementAndGet()
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onItemDamage(event: PlayerItemDamageEvent) {
if (event.player == attacker && event.item.type == Material.IRON_SWORD) {
itemDamageCount.addAndGet(event.damage)
}
}
}
runSync { Bukkit.getPluginManager().registerEvents(listener, testPlugin) }
try {
runSync {
Bukkit.getPluginManager().callEvent(
EntityDamageEvent(victim, EntityDamageEvent.DamageCause.CUSTOM, 0.1),
)
}
delayTicks(1)
appendDebug("expire:afterManualEvent allDamageEvents=${allDamageEventCount.get()}")
repeat(2) { attempt ->
runSync { attackNms(attacker, victim) }
waitUntilTicks(4) { hitCount.get() > attempt }
waitUntilTicks((victim.maximumNoDamageTicks + 2).toLong()) {
!victim.isValid || victim.noDamageTicks <= 0
}
}
} finally {
runSync { HandlerList.unregisterAll(listener) }
}
val hits = hitCount.get()
val totalHits = totalHitCount.get()
val cancelledHits = cancelledHitCount.get()
val damageEvents = itemDamageCount.get()
val actualDamage = getItemDamage(attacker.inventory.itemInMainHand)
if (totalHits == 0) {
val beforeHealth = victim.health
runSync { victim.damage(1.0, attacker) }
delayTicks(1)
appendDebug(
"expire:afterDamage totalHits=${totalHitCount.get()} " +
"cancelledHits=${cancelledHitCount.get()} healthBefore=$beforeHealth healthAfter=${victim.health}",
)
}
appendDebug(
"expire:hits=$hits totalHits=$totalHits cancelledHits=$cancelledHits " +
"victimEvents=${victimEventCount.get()} anyDamageEvents=${anyDamageEventCount.get()} " +
"allDamageEvents=${allDamageEventCount.get()} " +
"itemDamageEvents=$damageEvents itemDamage=$actualDamage",
)
if (hits < 2) {
repeat(4) {
runSync { attackNms(attacker, victim) }
waitUntilTicks((victim.maximumNoDamageTicks + 2).toLong()) {
hitCount.get() >= 2 || !victim.isValid || victim.noDamageTicks <= 0
}
}
}
val finalHits = hitCount.get()
val finalDamageEvents = itemDamageCount.get()
val finalItemDamage = getItemDamage(attacker.inventory.itemInMainHand)
val isModern =
kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
.versionIsNewerOrEqualTo(1, 12, 0)
if (!isModern) {
// Legacy 1.9 durability behaviour is inconsistent; ensure we at least don't crash.
return@withAttackerAndVictim
}
val expectedHits = 2
if (finalHits < expectedHits) {
error("Expected at least $expectedHits hits after invulnerability expiry, got $finalHits")
}
if (finalDamageEvents != finalHits || finalItemDamage != finalHits) {
error(
"Durability did not match hits: hits=$finalHits " +
"itemDamageEvents=$finalDamageEvents itemDamage=$finalItemDamage",
)
}
} catch (e: Throwable) {
appendDebug("expire:error=${e::class.java.simpleName}: ${e.message}")
throw e
}
}
}
})
+12
View File
@@ -0,0 +1,12 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
main: kernitus.plugin.OldCombatMechanics.OCMTestMain
name: OldCombatMechanicsTest
version: 0.0.1
authors: [ kernitus, Rayzr522 ]
description: Reverts to pre-1.9 combat mechanics
website: https://github.com/kernitus/BukkitOldCombatMechanics
load: POSTWORLD
softdepend: [ OldCombatMechanics ]
api-version: 1.13
@@ -8,16 +8,30 @@ package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.module.OCMModule;
import kernitus.plugin.OldCombatMechanics.utilities.EventRegistry;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public class ModuleLoader {
private static EventRegistry eventRegistry;
private static final List<OCMModule> modules = new ArrayList<>();
private static final Set<String> internalModules = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"modeset-listener",
"attack-cooldown-tracker",
"entity-damage-listener"
)));
public static void initialise(OCMMain plugin) {
modules.clear();
ModuleLoader.eventRegistry = new EventRegistry(plugin);
}
@@ -44,4 +58,33 @@ public class ModuleLoader {
public static List<OCMModule> getModules() {
return modules;
}
public static @Nullable OCMModule getConfigurableModule(String moduleName) {
final String normalised = normaliseModuleName(moduleName);
if (internalModules.contains(normalised)) return null;
return modules.stream()
.filter(module -> normaliseModuleName(module.getConfigName()).equals(normalised))
.findFirst()
.orElse(null);
}
public static Set<String> getConfigurableModuleNames() {
final Set<String> names = new HashSet<>();
for (OCMModule module : modules) {
final String name = normaliseModuleName(module.getConfigName());
if (!internalModules.contains(name)) {
names.add(name);
}
}
return Collections.unmodifiableSet(names);
}
public static void notifyPlayerStateChanged(@NotNull Player player) {
modules.forEach(module -> module.onModesetChange(player));
}
public static String normaliseModuleName(String moduleName) {
return moduleName == null ? "" : moduleName.toLowerCase(Locale.ROOT).trim();
}
}
@@ -6,11 +6,22 @@
package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class OCMConfigHandler {
private final String CONFIG_NAME = "config.yml";
@@ -25,8 +36,8 @@ public class OCMConfigHandler {
final File backup = getFile("config-backup.yml");
if (backup.exists()) backup.delete();
// Keeping YAML comments not available in lower versions
if (Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||
// Only auto-upgrade when Bukkit can parse and save YAML comments without stripping the default config.
if (CompatibilityCapabilities.canPreserveYamlComments() ||
Config.getConfig().getBoolean("force-below-1-18-1-config-upgrade", false)
) {
plugin.getLogger().warning("Config version does not match, upgrading old config");
@@ -43,8 +54,8 @@ public class OCMConfigHandler {
plugin.saveResource(CONFIG_NAME, true);
// Now, load the old values from the backup and the new config from the fresh file
final YamlConfiguration oldConfig = YamlConfiguration.loadConfiguration(backup);
final YamlConfiguration newConfig = YamlConfiguration.loadConfiguration(configFile);
final YamlConfiguration oldConfig = CompatibilityCapabilities.loadCommentPreservingYaml(backup);
final YamlConfiguration newConfig = CompatibilityCapabilities.loadCommentPreservingYaml(configFile);
// Copy user's values for keys that still exist
for (String key : newConfig.getKeys(true)) {
@@ -56,6 +67,8 @@ public class OCMConfigHandler {
}
}
migrateModuleLists(oldConfig, newConfig);
// Save the final, merged config
try {
newConfig.save(configFile);
@@ -86,6 +99,119 @@ public class OCMConfigHandler {
}
}
private void migrateModuleLists(YamlConfiguration oldConfig, YamlConfiguration newConfig) {
final Set<String> internalModules = new HashSet<>(Arrays.asList(
"modeset-listener",
"attack-cooldown-tracker",
"entity-damage-listener"
));
final Set<String> optionalModules = new HashSet<>(Arrays.asList(
"disable-attack-sounds",
"disable-sword-sweep-particles"
));
final Set<String> moduleNames = ModuleLoader.getModules().stream()
.map(module -> module.getConfigName().toLowerCase(Locale.ROOT))
.collect(Collectors.toCollection(LinkedHashSet::new));
final ConfigurationSection oldModesets = oldConfig.getConfigurationSection("modesets");
final Map<String, List<String>> migratedModesets = new LinkedHashMap<>();
final Set<String> modulesInModesets = new HashSet<>();
if (oldModesets != null) {
for (String modesetName : oldModesets.getKeys(false)) {
final List<String> moduleList = oldModesets.getStringList(modesetName);
final List<String> normalisedList = moduleList.stream()
.map(name -> name.toLowerCase(Locale.ROOT))
.collect(Collectors.toList());
normalisedList.removeIf(internalModules::contains);
migratedModesets.put(modesetName, normalisedList);
modulesInModesets.addAll(normalisedList);
}
}
moduleNames.addAll(optionalModules);
if (moduleNames.isEmpty()) {
for (String key : oldConfig.getKeys(true)) {
if (!key.endsWith(".enabled")) {
continue;
}
final String moduleName = key.substring(0, key.length() - ".enabled".length())
.toLowerCase(Locale.ROOT);
if (internalModules.contains(moduleName)) {
continue;
}
moduleNames.add(moduleName);
}
moduleNames.addAll(modulesInModesets);
}
moduleNames.removeAll(internalModules);
final List<String> alwaysEnabled = new ArrayList<>();
final List<String> disabledModules = new ArrayList<>();
for (String moduleName : moduleNames) {
final String enabledKey = moduleName + ".enabled";
if ("attack-range".equals(moduleName)) {
disabledModules.add(moduleName);
continue;
}
final boolean enabled = !oldConfig.contains(enabledKey) || oldConfig.getBoolean(enabledKey);
if (!enabled) {
disabledModules.add(moduleName);
continue;
}
if (!modulesInModesets.contains(moduleName)) {
alwaysEnabled.add(moduleName);
}
}
// Remove disabled modules from all modesets
if (!disabledModules.isEmpty()) {
for (Map.Entry<String, List<String>> entry : migratedModesets.entrySet()) {
entry.getValue().removeIf(disabledModules::contains);
}
}
newConfig.set("always_enabled_modules", alwaysEnabled);
newConfig.set("disabled_modules", disabledModules);
if (!migratedModesets.isEmpty()) {
ConfigurationSection targetModesets = newConfig.getConfigurationSection("modesets");
if (targetModesets == null) {
targetModesets = newConfig.createSection("modesets");
}
// Remove old keys that are no longer present, without deleting the section (keeps placement)
for (String key : new ArrayList<>(targetModesets.getKeys(false))) {
if (!migratedModesets.containsKey(key)) {
targetModesets.set(key, null);
}
}
// Apply migrated entries
for (Map.Entry<String, List<String>> entry : migratedModesets.entrySet()) {
targetModesets.set(entry.getKey(), entry.getValue());
}
}
final ConfigurationSection oldWorlds = oldConfig.getConfigurationSection("worlds");
boolean hasDefaultWorld = false;
if (oldWorlds != null) {
hasDefaultWorld = oldWorlds.contains("__default__");
for (String worldName : oldWorlds.getKeys(false)) {
newConfig.set("worlds." + worldName, oldWorlds.getStringList(worldName));
}
}
if (!hasDefaultWorld) {
final ConfigurationSection upgradedModesets = newConfig.getConfigurationSection("modesets");
if (upgradedModesets != null) {
newConfig.set("worlds.__default__", new ArrayList<>(upgradedModesets.getKeys(false)));
}
}
}
public YamlConfiguration getConfig(String fileName) {
return YamlConfiguration.loadConfiguration(getFile(fileName));
}
@@ -5,8 +5,10 @@
*/
package kernitus.plugin.OldCombatMechanics;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.ProtocolManager;
import com.github.retrooper.packetevents.PacketEvents;
import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPIImpl;
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandCompleter;
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandHandler;
import kernitus.plugin.OldCombatMechanics.hooks.PlaceholderAPIHook;
@@ -19,10 +21,8 @@ import kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker
import kernitus.plugin.OldCombatMechanics.utilities.damage.EntityDamageByEntityListener;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import kernitus.plugin.OldCombatMechanics.utilities.storage.ModesetListener;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerModuleOverrides;
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimpleBarChart;
import org.bstats.charts.SimplePie;
import org.bukkit.Bukkit;
import org.bukkit.entity.HumanEntity;
import org.bukkit.event.EventException;
@@ -30,30 +30,43 @@ import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.plugin.ServicePriority;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.logging.Logger;
import java.util.stream.Collectors;
public class OCMMain extends JavaPlugin {
private static final String BUILD_PROPERTIES_RESOURCE = "/ocm-build.properties";
private static OCMMain INSTANCE;
private static String buildCommit;
private final Logger logger = getLogger();
private final OCMConfigHandler CH = new OCMConfigHandler(this);
private final List<Runnable> disableListeners = new ArrayList<>();
private final List<Runnable> enableListeners = new ArrayList<>();
private final List<Hook> hooks = new ArrayList<>();
private ProtocolManager protocolManager;
private OldCombatMechanicsAPI api;
public OCMMain() {
super();
}
@Override
public void onLoad() {
PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this));
PacketEvents.getAPI().getSettings().checkForUpdates(false);
PacketEvents.getAPI().load();
}
@Override
public void onEnable() {
INSTANCE = this;
@@ -72,18 +85,15 @@ public class OCMMain extends JavaPlugin {
// Initialise the Messenger utility
Messenger.initialise(this);
try {
if (getServer().getPluginManager().getPlugin("ProtocolLib") != null &&
getServer().getPluginManager().getPlugin("ProtocolLib").isEnabled())
protocolManager = ProtocolLibrary.getProtocolManager();
} catch (Exception e) {
Messenger.warn("No ProtocolLib detected, some features might be disabled");
}
PacketEvents.getAPI().init();
// Register all the modules
registerModules();
// Register public API service
api = new OldCombatMechanicsAPIImpl(this);
Bukkit.getServicesManager().register(OldCombatMechanicsAPI.class, api, this, ServicePriority.Normal);
// Register all hooks for integrating with other plugins
registerHooks();
@@ -97,24 +107,6 @@ public class OCMMain extends JavaPlugin {
Config.reload();
// BStats Metrics
final Metrics metrics = new Metrics(this, 53);
// Simple bar chart
metrics.addCustomChart(
new SimpleBarChart(
"enabled_modules",
() -> ModuleLoader.getModules().stream()
.filter(OCMModule::isEnabled)
.collect(Collectors.toMap(OCMModule::toString, module -> 1))));
// Pie chart of enabled/disabled for each module
ModuleLoader.getModules().forEach(module -> metrics.addCustomChart(
new SimplePie(module.getModuleName() + "_pie",
() -> module.isEnabled() ? "enabled" : "disabled")));
enableListeners.forEach(Runnable::run);
// Properly handle Plugman load/unload.
final List<RegisteredListener> joinListeners = Arrays
.stream(PlayerJoinEvent.getHandlerList().getRegisteredListeners())
@@ -139,16 +131,12 @@ public class OCMMain extends JavaPlugin {
// Logging to console the enabling of OCM
final PluginDescriptionFile pdfFile = this.getDescription();
logger.info(pdfFile.getName() + " v" + pdfFile.getVersion() + " has been enabled");
logger.info(pdfFile.getName() + " v" + getDisplayVersion() + " has been enabled");
if (Config.moduleEnabled("update-checker"))
Bukkit.getScheduler().runTaskLaterAsynchronously(this,
() -> new UpdateChecker(this).performUpdate(), 20L);
metrics.addCustomChart(new SimplePie("auto_update_pie",
() -> Config.moduleSettingEnabled("update-checker",
"auto-update") ? "enabled" : "disabled"));
}
@Override
@@ -181,6 +169,15 @@ public class OCMMain extends JavaPlugin {
PlayerStorage.instantSave();
PlayerModuleOverrides.clearAll();
if (api != null) {
Bukkit.getServicesManager().unregister(OldCombatMechanicsAPI.class, api);
api = null;
}
PacketEvents.getAPI().terminate();
// Logging to console the disabling of OCM
logger.info(pdfFile.getName() + " v" + pdfFile.getVersion() + " has been disabled");
}
@@ -195,6 +192,7 @@ public class OCMMain extends JavaPlugin {
// Module listeners
ModuleLoader.addModule(new ModuleAttackCooldown(this));
ModuleLoader.addModule(new ModuleAttackRange(this));
// If below 1.16, we need to keep track of player attack cooldown ourselves
if (Reflector.getMethod(HumanEntity.class, "getAttackCooldown", 0) == null) {
@@ -242,13 +240,8 @@ public class OCMMain extends JavaPlugin {
ModuleLoader.addModule(new ModuleAttackFrequency(this));
ModuleLoader.addModule(new ModuleFishingRodVelocity(this));
// These modules require ProtocolLib
if (protocolManager != null) {
ModuleLoader.addModule(new ModuleAttackSounds(this));
ModuleLoader.addModule(new ModuleSwordSweepParticles(this));
} else {
Messenger.warn("No ProtocolLib detected, attack-sounds and sword-sweep-particles modules will be disabled");
}
ModuleLoader.addModule(new ModuleAttackSounds(this));
ModuleLoader.addModule(new ModuleSwordSweepParticles(this));
}
private void registerHooks() {
@@ -301,7 +294,38 @@ public class OCMMain extends JavaPlugin {
return INSTANCE.getDescription().getVersion();
}
public ProtocolManager getProtocolManager() {
return protocolManager;
public String getDisplayVersion() {
return formatDisplayVersion(getDescription().getVersion());
}
public static String formatDisplayVersion(String version) {
final String commit = getBuildCommit();
if (commit.isEmpty()) return version;
return version + " (commit " + commit + ")";
}
private static synchronized String getBuildCommit() {
if (buildCommit == null) buildCommit = loadBuildCommit();
return buildCommit;
}
private static String loadBuildCommit() {
final Properties properties = new Properties();
try (InputStream stream = OCMMain.class.getResourceAsStream(BUILD_PROPERTIES_RESOURCE)) {
if (stream == null) return "";
properties.load(stream);
} catch (IOException e) {
return "";
}
final String commit = properties.getProperty("commit", "").trim();
if (commit.isEmpty() || commit.contains("${")) return "";
return commit;
}
}
@@ -7,8 +7,8 @@ package kernitus.plugin.OldCombatMechanics;
import kernitus.plugin.OldCombatMechanics.updater.SpigetUpdateChecker;
import kernitus.plugin.OldCombatMechanics.utilities.Config;
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities;
import kernitus.plugin.OldCombatMechanics.utilities.Messenger;
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
@@ -27,7 +27,7 @@ public class UpdateChecker {
this.plugin = plugin;
// We don't really want to auto update if the config is not going to be upgraded automatically
autoDownload = Config.moduleSettingEnabled("update-checker", "auto-update") &&
(Reflector.versionIsNewerOrEqualTo(1, 18, 1) ||
(CompatibilityCapabilities.canPreserveYamlComments() ||
Config.getConfig().getBoolean("force-below-1-18-1-config-upgrade", false)
);
}
@@ -65,4 +65,4 @@ public class UpdateChecker {
messages.forEach(target);
}
}
}
@@ -0,0 +1,158 @@
/*
* 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.api
import org.bukkit.entity.Player
import org.bukkit.World
/**
* Java-facing public API for managing per-player module overrides.
*
* Overrides are online-session-only, in-memory state for currently online players.
* They are cleared when the player quits, when OldCombatMechanics is disabled, or
* when a plugin explicitly clears them through this API.
*
* API methods must be called 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.
*
* 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.
*
* Only configurable modules are valid inputs for methods that accept a module
* name. Internal or otherwise non-configurable modules are rejected with
* [IllegalArgumentException] and are not returned by [getModuleNames].
*
* Effective module state is resolved by checking per-player overrides first,
* then configured disabled modules, configured always-enabled modules, and
* finally the player's modeset and world configuration.
*
* Keep this surface friendly to Java callers. Future public API additions should use
* simple JVM-visible types and should avoid Kotlin-only shapes such as suspend functions,
* default parameters, function types, sealed or value classes, and companion-only access
* patterns.
*
* Obtain an instance from Bukkit's services manager and always null-check the
* registration before using it. The service is only available while
* OldCombatMechanics is loaded and enabled:
* ```
* RegisteredServiceProvider<OldCombatMechanicsAPI> registration = Bukkit.getServicesManager()
* .getRegistration(OldCombatMechanicsAPI.class);
* if (registration == null) {
* return;
* }
* OldCombatMechanicsAPI api = registration.getProvider();
* ```
*/
interface OldCombatMechanicsAPI {
/**
* Returns all configured modeset names in config iteration order.
*/
fun getModesetNames(): Set<String>
/**
* Returns the modesets allowed in [world] as an unmodifiable defensive copy.
*
* Iteration order 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.
*/
fun getAllowedModesets(world: World): Set<String>
/**
* Returns the stored modeset for [player] in their current world, or null
* when no player-specific modeset has been stored yet.
*/
fun getModesetForPlayer(player: Player): String?
/**
* Stores [modesetName] for [player] in their current world.
*
* The input name is normalised with [java.util.Locale.ROOT] before
* validation and storage. Unknown modesets, or modesets not allowed in the
* player's current world, are rejected.
*
* @throws IllegalArgumentException if [modesetName] is unknown or disallowed.
*/
fun setModesetForPlayer(player: Player, modesetName: String)
/**
* Forces configurable [moduleName] on for online [player]. The override is
* online-session-only and is cleared when explicitly cleared, when [player]
* quits, or when OldCombatMechanics is disabled.
*
* @throws IllegalArgumentException if [moduleName] is unknown or non-configurable.
*/
fun forceEnableModuleForPlayer(player: Player, moduleName: String)
/**
* Forces configurable [moduleName] off for online [player]. The override is
* online-session-only and is cleared when explicitly cleared, when [player]
* quits, or when OldCombatMechanics is disabled.
*
* @throws IllegalArgumentException if [moduleName] is unknown or non-configurable.
*/
fun forceDisableModuleForPlayer(player: Player, moduleName: String)
/**
* Clears the override for configurable [moduleName] for [player], reverting
* to configured module behaviour.
* No-op if no override is set.
*
* @throws IllegalArgumentException if [moduleName] is unknown or non-configurable.
*/
fun clearModuleOverrideForPlayer(player: Player, moduleName: String)
/**
* Clears all module overrides for [player], reverting to configured module behaviour.
* No-op if no overrides are set.
*/
fun clearAllModuleOverridesForPlayer(player: Player)
/**
* Returns the current override state for configurable [moduleName] for [player].
* Returns [PlayerModuleOverride.DEFAULT] if no override is set.
*
* @throws IllegalArgumentException if [moduleName] is unknown or non-configurable.
*/
fun getModuleOverrideForPlayer(player: Player, moduleName: String): PlayerModuleOverride
/**
* Returns all active overrides for [player] as a map of module name to [PlayerModuleOverride].
* Only modules with a non-DEFAULT override are included.
*/
fun getModuleOverridesForPlayer(player: Player): Map<String, PlayerModuleOverride>
/**
* Sets multiple module overrides for [player] at once.
* Entries with [PlayerModuleOverride.DEFAULT] are treated as clears.
* All entries are validated before any override state changes are applied.
*
* @throws IllegalArgumentException if any module name is unknown, non-configurable, or null,
* or if any override value is null.
*/
fun setModuleOverridesForPlayer(player: Player, overrides: Map<String, PlayerModuleOverride>)
/**
* Returns whether configurable [moduleName] is effectively enabled for [player],
* accounting for configured rules and any per-player override.
*
* @throws IllegalArgumentException if [moduleName] is unknown or non-configurable.
*/
fun isModuleEnabledForPlayer(player: Player, moduleName: String): Boolean
/**
* Returns whether [player] has any non-default override set.
*/
fun hasAnyOverrideForPlayer(player: Player): Boolean
/**
* Returns the names of all configurable modules that support per-player overrides.
*/
fun getModuleNames(): Set<String>
}
@@ -0,0 +1,198 @@
/*
* 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.api
import kernitus.plugin.OldCombatMechanics.ModuleLoader
import kernitus.plugin.OldCombatMechanics.OCMMain
import kernitus.plugin.OldCombatMechanics.module.OCMModule
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerModuleOverrides
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
import org.bukkit.Bukkit
import org.bukkit.World
import org.bukkit.entity.Player
import java.util.Locale
class OldCombatMechanicsAPIImpl(private val plugin: OCMMain) : OldCombatMechanicsAPI {
override fun getModesetNames(): Set<String> = Config.getModesetNames()
override fun getAllowedModesets(world: World): Set<String> = Config.getAllowedModesets(world)
override fun getModesetForPlayer(player: Player): String? =
PlayerStorage.getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid)
override fun setModesetForPlayer(player: Player, modesetName: String) {
val normalisedModesetName = modesetName.lowercase(Locale.ROOT)
validateModesetForPlayer(player, normalisedModesetName)
val worldId = player.world.uid
val playerData = PlayerStorage.getPlayerData(player.uniqueId)
val previousModeset = playerData.getModesetForWorld(worldId)
if (previousModeset == normalisedModesetName) {
return
}
playerData.setModesetForWorld(worldId, normalisedModesetName)
PlayerStorage.setPlayerData(player.uniqueId, playerData)
PlayerStorage.scheduleSave()
fireModesetChangeEvent(player, previousModeset, normalisedModesetName, PlayerModesetChangeEvent.Reason.API)
notifyPlayerStateChanged(player)
}
override fun forceEnableModuleForPlayer(player: Player, moduleName: String) {
setSingleModuleOverride(player, moduleName, PlayerModuleOverride.FORCE_ENABLED)
}
override fun forceDisableModuleForPlayer(player: Player, moduleName: String) {
setSingleModuleOverride(player, moduleName, PlayerModuleOverride.FORCE_DISABLED)
}
override fun clearModuleOverrideForPlayer(player: Player, moduleName: String) {
val normalisedModuleName = validateModuleName(moduleName)
val previousOverride = PlayerModuleOverrides.getOverride(player, normalisedModuleName)
if (previousOverride != PlayerModuleOverride.DEFAULT && PlayerModuleOverrides.clearOverride(player, normalisedModuleName)) {
fireModuleOverrideChangeEvent(player, normalisedModuleName, previousOverride, PlayerModuleOverride.DEFAULT)
notifyPlayerStateChanged(player)
}
}
override fun clearAllModuleOverridesForPlayer(player: Player) {
val changedOverrides = getModuleOverridesForPlayer(player).toSortedMap()
if (changedOverrides.isNotEmpty() && PlayerModuleOverrides.clearAll(player)) {
changedOverrides.forEach { (moduleName, previousOverride) ->
fireModuleOverrideChangeEvent(player, moduleName, previousOverride, PlayerModuleOverride.DEFAULT)
}
notifyPlayerStateChanged(player)
}
}
override fun getModuleOverrideForPlayer(player: Player, moduleName: String): PlayerModuleOverride {
val normalisedModuleName = validateModuleName(moduleName)
return PlayerModuleOverrides.getOverride(player, normalisedModuleName)
}
override fun getModuleOverridesForPlayer(player: Player): Map<String, PlayerModuleOverride> =
ModuleLoader.getConfigurableModuleNames()
.associateWith { PlayerModuleOverrides.getOverride(player, it) }
.filter { (_, override) -> override != PlayerModuleOverride.DEFAULT }
override fun setModuleOverridesForPlayer(player: Player, overrides: Map<String, PlayerModuleOverride>) {
val validatedOverrides = validateModuleOverrides(overrides)
val changes = validatedOverrides
.mapNotNull { (moduleName, override) ->
val previousOverride = PlayerModuleOverrides.getOverride(player, moduleName)
if (previousOverride == override) null else ModuleOverrideChange(moduleName, previousOverride, override)
}
.sortedBy { it.moduleName }
if (changes.isEmpty()) {
return
}
changes.forEach { (moduleName, _, override) ->
if (override == PlayerModuleOverride.DEFAULT) {
PlayerModuleOverrides.clearOverride(player, moduleName)
} else {
PlayerModuleOverrides.setOverride(player, moduleName, override)
}
}
changes.forEach { (moduleName, previousOverride, newOverride) ->
fireModuleOverrideChangeEvent(player, moduleName, previousOverride, newOverride)
}
notifyPlayerStateChanged(player)
}
override fun isModuleEnabledForPlayer(player: Player, moduleName: String): Boolean =
getConfigurableModule(moduleName).isEnabled(player)
override fun hasAnyOverrideForPlayer(player: Player): Boolean =
ModuleLoader.getConfigurableModuleNames()
.any { PlayerModuleOverrides.getOverride(player, it) != PlayerModuleOverride.DEFAULT }
override fun getModuleNames(): Set<String> = ModuleLoader.getConfigurableModuleNames()
private fun validateModesetForPlayer(player: Player, modesetName: String) {
if (!Config.getModesets().containsKey(modesetName)) {
throw IllegalArgumentException("Unknown modeset: $modesetName")
}
if (!Config.getAllowedModesets(player.world).contains(modesetName)) {
throw IllegalArgumentException("Modeset is not allowed in the player's current world: $modesetName")
}
}
private fun getConfigurableModule(moduleName: String): OCMModule =
ModuleLoader.getConfigurableModule(moduleName)
?: throw IllegalArgumentException("Unknown or non-configurable module: $moduleName")
private fun validateModuleName(moduleName: String): String {
val normalisedModuleName = ModuleLoader.normaliseModuleName(moduleName)
getConfigurableModule(normalisedModuleName)
return normalisedModuleName
}
private fun validateModuleOverrides(overrides: Map<String, PlayerModuleOverride>): List<Pair<String, PlayerModuleOverride>> {
val seenModuleNames = mutableSetOf<String>()
return (overrides as Map<*, *>).map { (moduleName, override) ->
val validatedModuleName = moduleName as? String
?: throw IllegalArgumentException("Module override name must not be null")
val validatedOverride = override as? PlayerModuleOverride
?: throw IllegalArgumentException("Module override value for $validatedModuleName must not be null")
val normalisedModuleName = validateModuleName(validatedModuleName)
if (!seenModuleNames.add(normalisedModuleName)) {
throw IllegalArgumentException("Duplicate module override name: $normalisedModuleName")
}
normalisedModuleName to validatedOverride
}
}
private fun setSingleModuleOverride(player: Player, moduleName: String, newOverride: PlayerModuleOverride) {
val normalisedModuleName = validateModuleName(moduleName)
val previousOverride = PlayerModuleOverrides.getOverride(player, normalisedModuleName)
if (previousOverride == newOverride) {
return
}
PlayerModuleOverrides.setOverride(player, normalisedModuleName, newOverride)
fireModuleOverrideChangeEvent(player, normalisedModuleName, previousOverride, newOverride)
notifyPlayerStateChanged(player)
}
private fun fireModesetChangeEvent(
player: Player,
previousModeset: String?,
newModeset: String?,
reason: PlayerModesetChangeEvent.Reason,
) {
Bukkit.getPluginManager().callEvent(
PlayerModesetChangeEvent(player, player.world, previousModeset, newModeset, reason),
)
}
private fun fireModuleOverrideChangeEvent(
player: Player,
moduleName: String,
previousOverride: PlayerModuleOverride,
newOverride: PlayerModuleOverride,
) {
Bukkit.getPluginManager().callEvent(
PlayerModuleOverrideChangeEvent(player, moduleName, previousOverride, newOverride),
)
}
private fun notifyPlayerStateChanged(player: Player) {
if (Bukkit.isPrimaryThread()) {
ModuleLoader.notifyPlayerStateChanged(player)
return
}
Bukkit.getScheduler().runTask(plugin, Runnable { ModuleLoader.notifyPlayerStateChanged(player) })
}
private data class ModuleOverrideChange(
val moduleName: String,
val previousOverride: PlayerModuleOverride,
val newOverride: PlayerModuleOverride,
)
}
@@ -0,0 +1,83 @@
/*
* 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.api;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Fired after a player's stored modeset changes and before OldCombatMechanics reapplies module state.
*/
public class PlayerModesetChangeEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
private final Player player;
private final World world;
private final String previousModeset;
private final String newModeset;
private final Reason reason;
public PlayerModesetChangeEvent(
@NotNull Player player,
@NotNull World world,
@Nullable String previousModeset,
@Nullable String newModeset,
@NotNull Reason reason
) {
this.player = player;
this.world = world;
this.previousModeset = previousModeset;
this.newModeset = newModeset;
this.reason = reason;
}
@NotNull
public Player getPlayer() {
return player;
}
@NotNull
public World getWorld() {
return world;
}
@Nullable
public String getPreviousModeset() {
return previousModeset;
}
@Nullable
public String getNewModeset() {
return newModeset;
}
@NotNull
public Reason getReason() {
return reason;
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
@NotNull
public static HandlerList getHandlerList() {
return HANDLERS;
}
public enum Reason {
API,
COMMAND,
WORLD_CHANGE,
JOIN
}
}
@@ -0,0 +1,15 @@
/*
* 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.api
/**
* Per-player override state for a module.
*/
enum class PlayerModuleOverride {
DEFAULT,
FORCE_ENABLED,
FORCE_DISABLED
}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more