diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..716779f35
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,23 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.java]
+end_of_line=lf
+
+[*.json]
+indent_size = 2
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.sh]
+end_of_line = lf
diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md
similarity index 72%
rename from ISSUE_TEMPLATE.md
rename to .github/ISSUE_TEMPLATE/bug_report.md
index 11f97984d..40ccc1285 100644
--- a/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,3 +1,12 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
_Please paste the output from `/usb version` below_
```
paste it here please (replace this text)
@@ -10,5 +19,3 @@ _What steps will reproduce the problem?_
_If you have any log-files, please paste them to [pastebin.com](http://pastebin.com)_
* server-log: link-to-pastebin
-
-(Not following the template will result in closing the issue.)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..11fc491ef
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/deploy-release.sh b/.github/deploy-release.sh
new file mode 100644
index 000000000..4fbbaae91
--- /dev/null
+++ b/.github/deploy-release.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+echo -e "Running release script...\n"
+echo -e "Publishing javadocs and artifacts...\n"
+cd $HOME
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/po-utils/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-API/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-APIv2/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Core/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-FAWE/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+echo -e "Publishing javadocs...\n"
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/po-utils/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/release/po-utils/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-API/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/release/uSkyBlock-API/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-APIv2/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/release/uSkyBlock-APIv2/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Core/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/release/uSkyBlock-Core/
+
+echo -e "Publishing final plugin release...\n"
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/uSkyBlock-*.jar \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/downloads/release/uSkyBlock/
+
+rsync -r --quiet --no-R --no-implied-dirs -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/classes/version.json \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/versions/release.json
diff --git a/.github/deploy-staging.sh b/.github/deploy-staging.sh
new file mode 100644
index 000000000..87ccfd640
--- /dev/null
+++ b/.github/deploy-staging.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+echo -e "Running staging script...\n"
+echo -e "Publishing javadocs and artifacts...\n"
+cd $HOME
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/po-utils/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-API/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-APIv2/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Core/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-FAWE/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/mvn-repo/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/maven/uskyblock/
+
+echo -e "Publishing javadocs...\n"
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/po-utils/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/master/po-utils/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-API/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/master/uSkyBlock-API/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-APIv2/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/master/uSkyBlock-APIv2/
+
+rsync -r --delete --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Core/target/site/apidocs/ \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/javadocs/master/uSkyBlock-Core/
+
+echo -e "Publishing final plugin release...\n"
+
+rsync -r --quiet -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/uSkyBlock-*.jar \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/downloads/master/uSkyBlock/
+
+rsync -r --quiet --no-R --no-implied-dirs -e "ssh -p 7685 -o StrictHostKeyChecking=no" \
+$HOME/work/uSkyBlock/uSkyBlock/uSkyBlock-Plugin/target/classes/version.json \
+u36810p330294@uskyblock.ovh:domains/uskyblock.ovh/public_html/versions/staging.json
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..0d30a3531
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,58 @@
+name: Maven build
+
+on:
+ push:
+ branches: [ master ]
+ tags: [ 'v*' ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ build_and_test:
+ if: github.repository_owner == 'uskyblock'
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ - name: Checkout submodules
+ run: |
+ sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules
+ git submodule update --init --recursive
+ - name: Generate additional language files
+ run: |
+ cd uSkyBlock-Core/src/main/po && perl en2pirate.pl && cd -
+ cd uSkyBlock-Core/src/main/po && perl en2kitteh.pl && cd -
+ - name: JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ - name: Install gettext
+ run: sudo apt-get install -y gettext
+ - name: Build with Maven
+ run: gradle build
+
+ # Install our SSH key:
+ - name: Install SSH key
+ uses: shimataro/ssh-key-action@v2
+ if: ${{ github.event_name == 'push' }}
+ with:
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ known_hosts: ${{ secrets.SSH_KNOWN_HOST }}
+
+ # Mark our scripts runnable:
+ - name: Mark deploy scripts runnable
+ if: ${{ github.event_name == 'push' }}
+ run: |
+ chmod +x "${GITHUB_WORKSPACE}/.github/deploy-staging.sh"
+ chmod +x "${GITHUB_WORKSPACE}/.github/deploy-release.sh"
+
+ # Deploy from master branch (staging release):
+ - name: Run deploy script for staging release
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ run: "${GITHUB_WORKSPACE}/.github/deploy-staging.sh"
+
+ # Deploy from tag create (plugin release):
+ - name: Run deploy script for plugin release
+ if: ${{ github.event_name == 'push' && startsWith( github.ref, 'refs/tags/') }}
+ run: "${GITHUB_WORKSPACE}/.github/deploy-release.sh"
diff --git a/.gitignore b/.gitignore
index 355e4d4d2..98e1d962f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,6 @@
.settings
.classpath
bin
-/dependency-reduced-pom.xml
# Package Files #
*.jar
*.war
@@ -14,5 +13,9 @@ bin
**/*.iml
**/*~
.idea
-**/target
-**/*.mo
\ No newline at end of file
+**/*.mo
+
+*.DS_Store
+
+dependency-reduced-pom.xml
+deploy_rsa
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
index 65af88c30..108b7e38b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule "bukkit-utils"]
path = bukkit-utils
- url = git@github.com:rlf/bukkit-utils.git
+ url = git@github.com:uskyblock/bukkit-utils.git
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 9c751d96c..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-language: java
-jdk:
-- openjdk8
-sudo: false
-cache:
- directories:
- - "$HOME/.m2"
-env:
- matrix:
- - MC=1.15
-git:
- submodules: false
-notifications:
- email: false
-before_install:
-- sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules
-- git submodule update --init --recursive
-- cd uSkyBlock-Core/src/main/po && perl en2pirate.pl && cd -
-- cd uSkyBlock-Core/src/main/po && perl en2kitteh.pl && cd -
-install:
-- mvn -nsu -Dtravis.buildNumber=${TRAVIS_BUILD_NUMBER} -Pi18n,${MC} clean deploy
-before_deploy:
- - echo '|1|DBFhltHRAVmrfmMZPLbN7FwnS5E=|79CP65+tOIeVNeNNC2R680mpV9o= ecdsa-sha2-nistp256
- AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK/krqsEUrVoDrYIc7I7nsiSCF0M7Xxr379IqZ2pLUPlxF2Or/MkTTokXzRsyspJazL1W1UrBDmKXHfO6+tyMMw='
- >> $HOME/.ssh/known_hosts
- - openssl aes-256-cbc -K $encrypted_77431b0955a8_key -iv $encrypted_77431b0955a8_iv
- -in deploy_rsa.enc -out deploy_rsa -d
- - eval "$(ssh-agent -s)"
- - chmod 600 deploy_rsa
- - ssh-add deploy_rsa
- - chmod +x scripts/deploy-staging.sh
- - chmod +x scripts/deploy-release.sh
-deploy:
- - provider: script
- skip_cleanup: true
- script: bash scripts/deploy-staging.sh
- on:
- branch: "master"
- - provider: script
- skip_cleanup: true
- script: bash scripts/deploy-release.sh
- on:
- tags: true
-after_success:
- - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- - chmod +x send.sh
- - ./send.sh success $WEBHOOK_URL
-after_failure:
- - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- - chmod +x send.sh
- - ./send.sh failure $WEBHOOK_URL
diff --git a/README.md b/README.md
index fe0c1e795..af9930c47 100644
--- a/README.md
+++ b/README.md
@@ -8,20 +8,44 @@ We are on [Spigot](https://www.spigotmc.org/resources/uskyblock-revived.66795/).
This version depends on the following plugins:
-* Bukkit/Spigot 1.15.1-R0.1-SNAPSHOT
+* Spigot/Paper 1.19-R0.1-SNAPSHOT
* Vault 1.7.x
-* WorldEdit 7.1.0-SNAPSHOT
-* WorldGuard 7.0.2-SNAPSHOT
-
-## Releases
-[](http://isitmaintained.com/project/rlf/uSkyBlock "Average time to resolve an issue") [](http://isitmaintained.com/project/rlf/uSkyBlock "Percentage of issues still open")
+* WorldEdit 7.2.13
+* WorldGuard 7.0.8-SNAPSHOT
+## Releases
https://www.spigotmc.org/resources/uskyblock-revived.66795/history
Pre-releases will end in -SNAPSHOT, and is considered **unsafe** for production servers.
Releases have a clean version number, has been tested, and should be safe for production servers.
+# New Maven group/artifactId
+Starting with version 3.0.0-SNAPSHOT, we've changed our Maven groupId's for all submodules except uSkyBlock-API.
+If you're using uSkyBlock-Core or po-utils as dependency in your project, update your
+dependencies to:
+
+```xml
+
+ ovh.uskyblock
+ uSkyBlock-Core
+ 3.0.0
+
+```
+
+We're moving new API features towards APIv2, which is available as:
+
+```xml
+
+ ovh.uskyblock
+ uSkyBlock-APIv2
+ 3.0.0
+
+```
+
+Feel free to use any of the new APIv2 functions on servers running uSkyBlock 3.0.0+. The old API-methods will
+be deprecated and removed in the upcoming plugin releases.
+
### Bukkit/Spigot 1.7.9/10 Releases
We provide pre-compiled versions (no support) [here](http://rlf.github.io/uSkyBlock):
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..d75cf5c7c
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,62 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ */
+
+plugins {
+ `maven-publish`
+ kotlin("jvm") version "2.1.10"
+}
+
+subprojects {
+ apply(plugin = "kotlin")
+ dependencies {
+ api(rootProject)
+ api(kotlin("script-runtime"))
+ kotlinScriptDef(rootProject)
+ }
+}
+
+allprojects {
+ repositories {
+ gradlePluginPortal()
+ mavenLocal()
+ mavenCentral()
+ maven("https://hub.spigotmc.org/nexus/content/repositories/public")
+ maven("https://papermc.io/repo/repository/maven-public/")
+ maven("https://maven.enginehub.org/repo/")
+ maven("https://oss.sonatype.org/content/repositories/snapshots/")
+ maven("https://repo.codemc.org/repository/maven-public")
+ maven("https://repo.mvdw-software.com/content/groups/public/")
+ maven("https://www.uskyblock.ovh/maven/dependencies/")
+ maven("https://www.uskyblock.ovh/maven/uskyblock/")
+ maven("https://repo.maven.apache.org/maven2/")
+ maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
+
+ maven("https://repo.onarandombox.com/content/groups/public")
+ maven("https://hub.spigotmc.org/nexus/content/groups/public/")
+ maven("https://jitpack.io")
+ maven("https://repo.minebench.de/")
+ maven("https://repo.maven.apache.org/maven2/")
+ }
+
+ java.sourceCompatibility = JavaVersion.VERSION_21
+ java.targetCompatibility = JavaVersion.VERSION_21
+ kotlin.jvmToolchain(21)
+
+ tasks.withType().configureEach {
+ enabled = false
+ }
+}
+
+group = "ovh.uskyblock"
+version = "3.2.0-SNAPSHOT"
+
+publishing {
+ publications.create("maven") {
+ from(components["java"])
+ }
+}
+
+tasks.withType() {
+ options.encoding = "UTF-8"
+}
diff --git a/bukkit-utils b/bukkit-utils
deleted file mode 160000
index f4b494106..000000000
--- a/bukkit-utils
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit f4b494106b4c748c1f60404a65a62c03165dd162
diff --git a/bukkit-utils/.editorconfig b/bukkit-utils/.editorconfig
new file mode 100644
index 000000000..716779f35
--- /dev/null
+++ b/bukkit-utils/.editorconfig
@@ -0,0 +1,23 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.java]
+end_of_line=lf
+
+[*.json]
+indent_size = 2
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.sh]
+end_of_line = lf
diff --git a/bukkit-utils/.github/workflows/build.yml b/bukkit-utils/.github/workflows/build.yml
new file mode 100644
index 000000000..e131b318c
--- /dev/null
+++ b/bukkit-utils/.github/workflows/build.yml
@@ -0,0 +1,42 @@
+name: Maven build
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ build_and_test:
+ if: github.repository_owner == 'uskyblock'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: JDK 16
+ uses: actions/setup-java@v1
+ with:
+ java-version: '16'
+ distribution: 'adopt'
+ - name: Build with Maven
+ run: gradle build
+
+ # Deploy steps when pushed to master
+ - name: Install SSH key
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ known_hosts: ${{ secrets.SSH_KNOWN_HOST }}
+ - name: Rsync deploy mvn repo
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ run: |
+ rsync -r --quiet -e "ssh -p 2222 -o StrictHostKeyChecking=no" \
+ target/mvn-repo/ \
+ travis@travis.internetpolice.eu:WWW-USB/maven/uskyblock/
+ - name: Rsync deploy javadocs
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ run: |
+ rsync -r --quiet -e "ssh -p 2222 -o StrictHostKeyChecking=no" \
+ target/site/apidocs \
+ travis@travis.internetpolice.eu:WWW-USB/javadocs/dependencies/bukkit-utils/
diff --git a/bukkit-utils/.gitignore b/bukkit-utils/.gitignore
new file mode 100644
index 000000000..520582144
--- /dev/null
+++ b/bukkit-utils/.gitignore
@@ -0,0 +1,4 @@
+/.idea
+**/*.iml
+/target
+deploy_rsa
diff --git a/bukkit-utils/README.md b/bukkit-utils/README.md
new file mode 100644
index 000000000..b142362fa
--- /dev/null
+++ b/bukkit-utils/README.md
@@ -0,0 +1,48 @@
+# bukkit-utils
+
+This module holds general Bukkit Utilities enabling easier Bukkit Plugin creation.
+
+## Utilities
+
+* [FileUtil](src/main/java/dk/lockfuglsang/minecraft/file/README.md) - UTF-8, Locale and merging config files from jar
+* [YmlConfiguration](src/main/java/dk/lockfuglsang/minecraft/yml/README.md) - Support for comments in yml-files
+* [Commands](src/main/java/dk/lockfuglsang/minecraft/command/README.md) - Framework for easy command-creation
+
+# License
+
+This module is copyrighted by the authors, and licensed for re-use as Apache License 2.0.
+
+# Usage
+
+Put this in your `pom.xml`:
+
+```
+
+
+ uSkyBlock-mvn-repo
+ https://raw.github.com/rlf/mvn-repo/master
+
+
+
+
+ dk.lockfuglsang.minecraft
+ bukkit-utils
+ 1.22
+
+
+```
+
+# Version History
+
+## 1.22 - Bukkit 1.13 compatible
+
+## 1.21 - Bukkit 1.12 compatible
+
+## v1.1
+
+* FileUtil, I18nUtil
+
+## v1.0
+Initial release, extracted from the source-code used in uSkyBlock
+
+* YmlConfiguration, Commands
\ No newline at end of file
diff --git a/bukkit-utils/build.gradle.kts b/bukkit-utils/build.gradle.kts
new file mode 100644
index 000000000..f8a731090
--- /dev/null
+++ b/bukkit-utils/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ */
+
+dependencies {
+ implementation(project(":po-utils"))
+ implementation("org.spigotmc:spigot-api:1.20.6-R0.1-SNAPSHOT")
+ testImplementation("org.hamcrest:hamcrest-core:1.3")
+ testImplementation("org.hamcrest:hamcrest-library:1.3")
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.mockito:mockito-core:5.14.2")
+ testImplementation("com.google.code.gson:gson:2.8.7")
+}
+
+description = "bukkit-utils"
+
+val testsJar by tasks.registering(Jar::class) {
+ archiveClassifier.set("tests")
+ from(sourceSets["test"].output)
+}
+
+configurations {
+ create("testsJar") {
+ isCanBeConsumed = true
+ isCanBeResolved = false
+ outgoing.artifact(testsJar)
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/Animation.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/Animation.java
new file mode 100644
index 000000000..42b643ee9
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/Animation.java
@@ -0,0 +1,14 @@
+package dk.lockfuglsang.minecraft.animation;
+
+import org.bukkit.entity.Player;
+
+/**
+ * Common interface for animations
+ */
+public interface Animation {
+ boolean show();
+
+ boolean hide();
+
+ Player getPlayer();
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/AnimationHandler.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/AnimationHandler.java
new file mode 100644
index 000000000..47f13220c
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/AnimationHandler.java
@@ -0,0 +1,116 @@
+package dk.lockfuglsang.minecraft.animation;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitRunnable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Handles particles and per-player block-animations
+ */
+public class AnimationHandler {
+ private final Map> animations = new ConcurrentHashMap<>();
+ private final Map animationTasks = new ConcurrentHashMap<>();
+ private final Plugin plugin;
+
+ private int animTick;
+
+ public AnimationHandler(Plugin plugin) {
+ this.plugin = plugin;
+ animTick = plugin.getConfig().getInt("animations.tick", 20);
+ }
+
+ public void setAnimTick(int animTick) {
+ this.animTick = animTick;
+ plugin.getConfig().set("animations.tick", animTick);
+ }
+
+ public synchronized void addAnimation(Animation animation) {
+ if (!animations.containsKey(animation.getPlayer().getUniqueId())) {
+ animations.put(animation.getPlayer().getUniqueId(), new HashSet());
+ }
+ animations.get(animation.getPlayer().getUniqueId()).add(animation);
+ start();
+ }
+
+ public synchronized boolean removeAnimations(Player player) {
+ Set animSet = animations.remove(player.getUniqueId());
+ if (animSet == null) {
+ return false;
+ }
+ for (Animation animation : animSet) {
+ animation.hide();
+ }
+ return true;
+ }
+
+ public synchronized void start() {
+ for (UUID uuid : animations.keySet()) {
+ AnimationTask animationTask = animationTasks.get(uuid);
+ if (animationTask == null && animations.get(uuid) != null && !animations.get(uuid).isEmpty()) {
+ animationTask = new AnimationTask(uuid);
+ animationTask.runTaskTimerAsynchronously(plugin, 0, animTick);
+ animationTasks.put(uuid, animationTask);
+ }
+ }
+ }
+
+ public synchronized void stop() {
+ for (UUID uuid : animations.keySet()) {
+ AnimationTask animationTask = animationTasks.get(uuid);
+ if (animationTask != null) {
+ animationTask.cancel();
+ animationTasks.remove(uuid);
+ }
+ }
+ if (plugin.isEnabled()) {
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, new Runnable() {
+ @Override
+ public void run() {
+ Collection> anims = new ArrayList<>(animations.values());
+ for (Set animSet : anims) {
+ for (Animation animation : animSet) {
+ animation.hide();
+ }
+ }
+ }
+ });
+ }
+ }
+
+ private class AnimationTask extends BukkitRunnable {
+ private final UUID uniqueId;
+ public AnimationTask(UUID uniqueId) {
+ this.uniqueId = uniqueId;
+ }
+
+ @Override
+ public void run() {
+ // Copy - to avoid ConcurrentModificationException
+ Set animSet = animations.get(uniqueId);
+ Set animCopy = (animSet != null) ? new HashSet<>(animSet) : Collections.emptySet();
+ for (Animation animation : animCopy) {
+ if (!animation.show()) {
+ UUID uuid = animation.getPlayer().getUniqueId();
+ animations.get(uuid).remove(animation);
+ if (animations.get(uuid).isEmpty()) {
+ animations.remove(uuid);
+ }
+ }
+ }
+ if (animations.get(uniqueId) == null) {
+ cancel();
+ animationTasks.remove(uniqueId);
+ }
+ }
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/BlockAnimation.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/BlockAnimation.java
new file mode 100644
index 000000000..1f27df819
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/animation/BlockAnimation.java
@@ -0,0 +1,54 @@
+package dk.lockfuglsang.minecraft.animation;
+
+import org.bukkit.Location;
+import org.bukkit.block.data.BlockData;
+import org.bukkit.entity.Player;
+
+import java.util.List;
+
+/**
+ * Sends (bogus) block-info to the player
+ */
+public class BlockAnimation implements Animation {
+ private final Player player;
+ private final List points;
+ private final BlockData blockData;
+ private volatile boolean shown;
+
+ public BlockAnimation(Player player, List points, BlockData blockData) {
+ this.player = player;
+ this.points = points;
+ this.blockData = blockData;
+ shown = false;
+ }
+
+ @Override
+ public boolean show() {
+ if (shown) {
+ return true;
+ }
+ if (!player.isOnline()) {
+ return false;
+ }
+ for (Location loc : points) {
+ player.sendBlockChange(loc, blockData);
+ }
+ shown = true;
+ return true;
+ }
+
+ @Override
+ public boolean hide() {
+ if (shown) {
+ shown = false;
+ player.sendBlockChanges(points.stream().map(loc -> loc.getBlock().getState()).toList());
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public Player getPlayer() {
+ return player;
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/AbstractCommand.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/AbstractCommand.java
new file mode 100644
index 000000000..a86d973ea
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/AbstractCommand.java
@@ -0,0 +1,121 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.po.I18nUtil;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.entity.Player;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Convenience implementation of the Command
+ */
+public abstract class AbstractCommand implements Command {
+ private final String[] aliases;
+ private final String permission;
+ private final String description;
+ private final String usage;
+ private final String[] params;
+ private CompositeCommand parent;
+ private final Map featurePerms = new HashMap<>();
+ private final Set permissionOverride;
+
+ public AbstractCommand(String name, String permission, String params, String description, String usage, UUID... permissionOverride) {
+ this.aliases = name.split("\\|");
+ this.permission = permission;
+ this.description = I18nUtil.tr(description);
+ this.usage = I18nUtil.tr(usage);
+ this.params = params != null && !params.trim().isEmpty() ? params.split(" ") : new String[0];
+ this.permissionOverride = new HashSet<>(Arrays.asList(permissionOverride));
+ }
+
+ public AbstractCommand(String name, String permission, String params, String description) {
+ this(name, permission, params, description, null);
+ }
+
+ public AbstractCommand(String name, String permission, String description) {
+ this(name, permission, null, description, null);
+ }
+
+ public AbstractCommand(String name, String description) {
+ this(name, null, null, description, null);
+ }
+
+ @Override
+ public String getName() {
+ return aliases[0];
+ }
+
+ public String[] getAliases() {
+ return aliases;
+ }
+
+ @Override
+ public String getPermission() {
+ return permission;
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public String getUsage() {
+ return usage;
+ }
+
+ @Override
+ public String[] getParams() {
+ return params;
+ }
+
+ @Override
+ public TabCompleter getTabCompleter() {
+ return null;
+ }
+
+ @Override
+ public CompositeCommand getParent() {
+ return parent;
+ }
+
+ @Override
+ public void setParent(CompositeCommand parent) {
+ this.parent = parent;
+ }
+
+ @Override
+ public void accept(CommandVisitor visitor) {
+ if (visitor != null) {
+ visitor.visit(this);
+ }
+ }
+
+ public void addFeaturePermission(String perm, String description) {
+ featurePerms.put(perm, description);
+ }
+
+ @Override
+ public Map getFeaturePermissions() {
+ return Collections.unmodifiableMap(featurePerms);
+ }
+
+ public boolean hasPermissionOverride(CommandSender sender) {
+ if (sender instanceof Player) {
+ return permissionOverride.contains(((Player) sender).getUniqueId()) || (parent != null && parent.hasPermissionOverride(sender));
+ }
+ return false;
+ }
+
+ @Override
+ public boolean hasPermission(CommandSender sender, String permission) {
+ return hasPermissionOverride(sender) || sender.hasPermission(permission);
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutor.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutor.java
new file mode 100644
index 000000000..26fc68308
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutor.java
@@ -0,0 +1,46 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+
+import java.util.HashMap;
+import java.util.UUID;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * Command delegator.
+ */
+public class BaseCommandExecutor extends CompositeCommand implements CommandExecutor {
+
+ public BaseCommandExecutor(String name, String permission, String description) {
+ super(name, permission, description);
+ }
+
+ public BaseCommandExecutor(String name, String permission, String params, String description) {
+ super(name, permission, params, description);
+ }
+ public BaseCommandExecutor(String name, String permission, String params, String description, UUID... permissionOverrides) {
+ super(name, permission, params, description, permissionOverrides);
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String alias, String[] args) {
+ if (!CommandManager.isRequirementsMet(sender, this, args)) {
+ return true;
+ }
+ dk.lockfuglsang.minecraft.command.Command cmd = this;
+ if (!hasAccess(cmd, sender)) {
+ if (cmd != null) {
+ sender.sendMessage(tr("\u00a7eYou do not have access (\u00a74{0}\u00a7e)", cmd.getPermission()));
+ } else {
+ sender.sendMessage(tr("\u00a7eInvalid command: {0}", alias));
+ }
+ showUsage(sender, 1);
+ } else {
+ return execute(sender, alias, new HashMap(), args);
+ }
+ return true;
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/Command.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/Command.java
new file mode 100644
index 000000000..58c21b2a2
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/Command.java
@@ -0,0 +1,89 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+
+import java.util.Map;
+
+/**
+ * An abstraction for supporting nesting of commands.
+ * This is a light-weight version of the BukkitCommand abstraction.
+ */
+public interface Command {
+ /**
+ * Returns the name of the sub-command.
+ */
+ String getName();
+
+ /**
+ * The permission of the command. Can be null.
+ */
+ String getPermission();
+
+ /**
+ * A short description of the sub-command.
+ * Used when listing the commands with others.
+ */
+ String getDescription();
+
+ /**
+ * A more verbatim description of the command.
+ * Used when /command help is executed.
+ */
+ String getUsage();
+
+ /**
+ * The list of parameters accepted by the command.
+ * Can be empty, not null.
+ */
+ String[] getParams();
+
+ /**
+ * Returns aliases for the command.
+ * Can be empty, cannot be null.
+ */
+ String[] getAliases();
+
+ /**
+ * Executes the command.
+ */
+ boolean execute(CommandSender sender, String alias, Map data, String... args);
+
+ /**
+ * Optional TabCompleter to override the default ones.
+ * Can be null
+ */
+ TabCompleter getTabCompleter();
+
+ /**
+ * Returns the parent command (if one such is available).
+ * May return null.
+ */
+ CompositeCommand getParent();
+
+ /**
+ * Assigns a parent command.
+ * @param parent
+ */
+ void setParent(CompositeCommand parent);
+
+ /**
+ * Visitor pattern.
+ * @param visitor A visitor for this node.
+ */
+ void accept(CommandVisitor visitor);
+
+ /**
+ * Returns a map of feature-toggling permissions supporte by this command.
+ * @return A map of permission-node as key, and description as value.
+ */
+ Map getFeaturePermissions();
+
+ /**
+ * Allows for other than permission checking
+ * @param sender The commandSender requesting permission
+ * @param permission The permission
+ * @return true if the sender can execute this command
+ */
+ boolean hasPermission(CommandSender sender, String permission);
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandComparator.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandComparator.java
new file mode 100644
index 000000000..e015dd07b
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandComparator.java
@@ -0,0 +1,13 @@
+package dk.lockfuglsang.minecraft.command;
+
+import java.util.Comparator;
+
+/**
+ * Comparator for sorting sub-commands
+ */
+public class CommandComparator implements Comparator {
+ @Override
+ public int compare(Command o1, Command o2) {
+ return o1.getName().compareTo(o2.getName());
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandManager.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandManager.java
new file mode 100644
index 000000000..6d345ef8c
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandManager.java
@@ -0,0 +1,31 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.bukkit.command.CommandSender;
+
+/**
+ * Static singleton manager for controlling common requirements.
+ */
+public enum CommandManager {;
+ private static RequirementChecker checker;
+
+ public static void registerRequirements(RequirementChecker reqs) {
+ checker = reqs;
+ }
+
+ public static boolean isRequirementsMet(CommandSender sender, Command command, String... args) {
+ if (checker != null) {
+ return checker.isRequirementsMet(sender, command, args);
+ }
+ return true;
+ }
+
+ public interface RequirementChecker {
+ /**
+ * Checks whether the requirements for the command has been met.
+ * @param sender A sender to send detailed feedback to.
+ * @param args
+ * @return true iff the command can proceed.
+ */
+ boolean isRequirementsMet(CommandSender sender, Command command, String... args);
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandVisitor.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandVisitor.java
new file mode 100644
index 000000000..e0dc42910
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CommandVisitor.java
@@ -0,0 +1,8 @@
+package dk.lockfuglsang.minecraft.command;
+
+/**
+ * Simple visitor for the USBCommands
+ */
+public interface CommandVisitor {
+ void visit(Command cmd);
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CompositeCommand.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CompositeCommand.java
new file mode 100644
index 000000000..0c18f174c
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/CompositeCommand.java
@@ -0,0 +1,365 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.command.completion.AbstractTabCompleter;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * Command with nested commandMap inside.
+ */
+public class CompositeCommand extends AbstractTabCompleter implements Command, TabCompleter {
+
+ public static final String HELP_PATTERN = "(?iu)help|\\?";
+ private static final int MAX_PER_PAGE = 10;
+
+ private final String name;
+ private final String[] aliases;
+ private final String permission;
+ private final String description;
+ private final String[] params;
+ private CompositeCommand parent;
+ private final Map commandMap;
+ private final Map aliasMap;
+ private final Map tabMap;
+ private final Map featurePerms;
+ private final Set permissionOverride;
+
+ public CompositeCommand(String name, String permission, String description) {
+ this(name, permission, null, description);
+ }
+
+ public CompositeCommand(String name, String permission, String params, String description, UUID... permissionOverride) {
+ this.aliases = name.split("\\|");
+ this.name = aliases[0];
+ this.permission = permission;
+ this.description = description;
+ this.params = params != null ? params.split(" ") : new String[0];
+ commandMap = new HashMap<>();
+ aliasMap = new HashMap<>();
+ tabMap = new HashMap<>();
+ featurePerms = new HashMap<>();
+ this.permissionOverride = new HashSet<>(Arrays.asList(permissionOverride));
+ }
+
+ public CompositeCommand add(Command... cmds) {
+ for (Command cmd : cmds) {
+ commandMap.put(cmd.getName(), cmd);
+ for (String alias : cmd.getAliases()) {
+ aliasMap.put(alias, cmd);
+ }
+ cmd.setParent(this);
+ }
+ return this;
+ }
+
+ public CompositeCommand addTab(String arg, TabCompleter tab) {
+ tabMap.put(arg, tab);
+ return this;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String[] getAliases() {
+ return aliases;
+ }
+
+ @Override
+ public String getPermission() {
+ return permission != null && !permission.isEmpty() ? permission : null;
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public String getUsage() {
+ return null;
+ }
+
+ @Override
+ public String[] getParams() {
+ return params;
+ }
+
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ if (!CommandManager.isRequirementsMet(sender, this, args)) {
+ return true;
+ }
+ if (args.length == 0 || (args.length == 1 && args[0].matches(HELP_PATTERN))) {
+ showUsage(sender, 1);
+ } else if (args.length > 1 && args[0].matches(HELP_PATTERN)) {
+ showUsage(sender, args[1]);
+ } else if (args.length > params.length && hasAccess(this, sender)) {
+ String cmdName = args[params.length].toLowerCase();
+ Command cmd = aliasMap.get(cmdName);
+ String[] subArgs = new String[args.length - 1 - params.length];
+ System.arraycopy(args, 1 + params.length, subArgs, 0, subArgs.length);
+ int ix = 0;
+ for (String p : params) {
+ data.put(p, args[ix++]);
+ }
+ if (!hasAccess(cmd, sender)) {
+ if (cmd != null) {
+ sender.sendMessage(tr("\u00a7eYou do not have access (\u00a74{0}\u00a7e)", cmd.getPermission()));
+ } else {
+ sender.sendMessage(tr("\u00a7eInvalid command: {0}", cmdName));
+ }
+ showUsage(sender, args[0]);
+ } else if (!cmd.execute(sender, cmdName, data, subArgs)) {
+ showUsage(sender, args[0]);
+ }
+ } else {
+ showUsage(sender, 1);
+ }
+ return true;
+ }
+
+ public void showUsage(CommandSender sender, int page) {
+ String msg = tr("\u00a77Usage: {0}", getShortDescription(sender, this));
+ if (!hasAccess(this, sender)) {
+ sender.sendMessage(msg.split("\n"));
+ return;
+ }
+ if (getUsage() != null && !getUsage().isEmpty()) {
+ msg += "\u00a77" + getUsage();
+ }
+ List cmds = new ArrayList<>(commandMap.keySet());
+ Collections.sort(cmds);
+ cmds = cmds.stream().filter(f -> hasAccess(commandMap.get(f), sender)).collect(Collectors.toList());
+ int realPage = 0;
+ int maxPage = 0;
+ if (cmds.size() > MAX_PER_PAGE) {
+ msg = msg.substring(0, msg.length() - 1); // Remove \n
+ maxPage = (int) Math.round(Math.ceil(cmds.size() * 1f / MAX_PER_PAGE));
+ realPage = Math.max(1, Math.min(maxPage, page));
+ msg += " \u00a77[" + realPage + "/" + maxPage + "]\n";
+ cmds = cmds.subList((realPage - 1) * MAX_PER_PAGE, Math.min(realPage * MAX_PER_PAGE, cmds.size()));
+ }
+ for (String key : cmds) {
+ Command cmd = commandMap.get(key);
+ if (hasAccess(cmd, sender)) {
+ msg += " " + getShortDescription(sender, cmd);
+ }
+ }
+ if (realPage > 0 && maxPage > realPage) {
+ msg += tr("\u00a77Use \u00a73/{0} ? {1}\u00a77 to display next page\n", getName(), (realPage + 1));
+ } else if (realPage > 0 && maxPage == realPage) {
+ msg += tr("\u00a77Use \u00a73/{0} ? {1} \u00a77 to display previous page\n", getName(), (realPage - 1));
+ }
+ sender.sendMessage(msg.split("\n"));
+ }
+
+ protected void showUsage(CommandSender sender, String arg) {
+ String cmdName = arg.toLowerCase();
+ Command cmd = cmdName.isEmpty() ? this : aliasMap.get(cmdName);
+ if (cmd != null && hasAccess(cmd, sender)) {
+ String msg = tr("\u00a77Usage: {0}", name) + " \u00a7e";
+ msg += getShortDescription(sender, cmd);
+ if (cmd.getUsage() != null && !cmd.getUsage().isEmpty()) {
+ msg += "\u00a77" + cmd.getUsage();
+ }
+ sender.sendMessage(msg.split("\n"));
+ } else if (cmdName.matches("[0-9]+")) {
+ showUsage(sender, Integer.parseInt(cmdName));
+ } else {
+ List cmds = filter(new ArrayList<>(aliasMap.keySet()), cmdName);
+ if (cmds.isEmpty()) {
+ showUsage(sender, 1);
+ } else {
+ String msg = tr("\u00a77Usage: {0}", getShortDescription(sender, this));
+ if (hasAccess(this, sender)) {
+ for (String key : cmds) {
+ Command scmd = commandMap.get(key);
+ if (scmd != null && hasAccess(scmd, sender)) {
+ msg += " " + getShortDescription(sender, scmd);
+ }
+ }
+ }
+ sender.sendMessage(msg.split("\n"));
+ }
+ }
+ }
+
+ private String getShortDescription(CommandSender sender, Command cmd) {
+ String msg = "\u00a73" + cmd.getName();
+ String[] aliases = cmd.getAliases();
+ if (aliases.length > 1) {
+ msg += "\u00a77";
+ for (int i = 1; i < aliases.length; i++) {
+ msg += " | " + aliases[i];
+ }
+ }
+ msg += "\u00a7a";
+ msg += getParamsAsString(cmd);
+ if (cmd instanceof CompositeCommand) {
+ msg += " [command|help]";
+ }
+ msg += "\u00a77 - \u00a7e";
+ msg += cmd.getDescription();
+ if (sender.isOp() && cmd.getPermission() != null) {
+ msg += " \u00a7c(" + cmd.getPermission() + ")";
+ }
+ msg += "\n";
+ return msg;
+ }
+
+ public static String getParamsAsString(Command cmd) {
+ String msg = "";
+ for (String param : cmd.getParams()) {
+ if (param.startsWith("?")) {
+ msg += " [" + param.substring(1) + "]";
+ } else {
+ msg += " <" + param + ">";
+ }
+ }
+ return msg;
+ }
+
+ public boolean hasAccess(Command cmd, CommandSender sender) {
+ return cmd != null && (cmd.getPermission() == null || cmd.hasPermission(sender, cmd.getPermission()));
+ }
+
+ @Override
+ protected List getTabList(CommandSender commandSender, String term) {
+ ArrayList strings = new ArrayList<>();
+ if (!hasAccess(this, commandSender)) {
+ return strings;
+ }
+ for (Command cmd : commandMap.values()) {
+ if (hasAccess(cmd, commandSender)) {
+ strings.addAll(Arrays.asList(cmd.getAliases()));
+ }
+ }
+ strings.add("help");
+ return strings;
+ }
+
+ protected TabCompleter getTabCompleter(Command cmd, int argNum) {
+ argNum = argNum >= 0 ? argNum : 0;
+ if (cmd.getParams().length > argNum) {
+ String paramName = cmd.getParams()[argNum];
+ if (paramName != null && paramName.startsWith("?")) {
+ paramName = paramName.substring(1);
+ }
+ if (tabMap.containsKey(paramName)) {
+ return tabMap.get(paramName);
+ } else if (getParent() != null) {
+ TabCompleter tab = getParent().getTabCompleter(cmd, argNum);
+ if (tab != null) {
+ return tab;
+ }
+ }
+ }
+ if (cmd.getTabCompleter() != null) {
+ return cmd.getTabCompleter();
+ }
+ return null;
+ }
+
+ @Override
+ public List onTabComplete(CommandSender sender, org.bukkit.command.Command command, String alias, String[] args) {
+ if (args.length <= params.length && args.length > 0) {
+ TabCompleter tab = getTabCompleter(this, args.length - 1);
+ if (tab != null && tab != this) {
+ return tab.onTabComplete(sender, command, alias, args);
+ } else if (tab == this) {
+ return getTabList(sender, args[args.length - 1]);
+ }
+ } else if (args.length > params.length + 1) { // Sub-commands
+ String cmdName = args[params.length].toLowerCase();
+ Command cmd = aliasMap.get(cmdName);
+ if (cmd != null && (args.length - params.length) > 1) { // Go deeper
+ String[] subArgs = new String[args.length - 1 - params.length];
+ System.arraycopy(args, 1 + params.length, subArgs, 0, subArgs.length);
+ TabCompleter tab = getTabCompleter(cmd, subArgs.length - 1);
+ if (tab != null) {
+ return tab.onTabComplete(sender, command, alias, subArgs);
+ }
+ } else {
+ return super.onTabComplete(sender, command, alias, args);
+ }
+ } else {
+ return super.onTabComplete(sender, command, alias, args);
+ }
+ return Collections.emptyList();
+ }
+
+ @Override
+ public TabCompleter getTabCompleter() {
+ return this;
+ }
+
+ @Override
+ public CompositeCommand getParent() {
+ return parent;
+ }
+
+ @Override
+ public void setParent(CompositeCommand parent) {
+ this.parent = parent;
+ }
+
+ public List getChildren() {
+ ArrayList list = new ArrayList<>(commandMap.values());
+ Collections.sort(list, new CommandComparator());
+ return Collections.unmodifiableList(list);
+ }
+
+ @Override
+ public void accept(CommandVisitor visitor) {
+ if (visitor != null) {
+ visitor.visit(this);
+ for (Command child : getChildren()) {
+ child.accept(visitor);
+ }
+ }
+ }
+
+ public void addFeaturePermission(String perm, String description) {
+ featurePerms.put(perm, description);
+ }
+
+ @Override
+ public Map getFeaturePermissions() {
+ return Collections.unmodifiableMap(featurePerms);
+ }
+
+ private boolean hasPermissionOverride(UUID uuid) {
+ return permissionOverride.contains(uuid) || (getParent() != null && getParent().hasPermissionOverride(uuid));
+ }
+
+ public boolean hasPermissionOverride(CommandSender sender) {
+ if (sender instanceof Player) {
+ return hasPermissionOverride(((Player) sender).getUniqueId());
+ }
+ return false;
+ }
+
+ public boolean hasPermission(CommandSender sender, String permission) {
+ if (sender instanceof Player) {
+ return hasPermissionOverride(((Player) sender).getUniqueId()) || sender.hasPermission(permission);
+ }
+ return sender.hasPermission(permission);
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentCommand.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentCommand.java
new file mode 100644
index 000000000..f1b95699e
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentCommand.java
@@ -0,0 +1,80 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.command.completion.AbstractTabCompleter;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.PluginCommand;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.marktr;
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * Command that traverses all the commands in a plugin, and generates documentation for them.
+ */
+public class DocumentCommand extends AbstractCommand {
+ public static final List FORMATS = Arrays.asList("text", "yml");
+ private final JavaPlugin plugin;
+ private TabCompleter tabCompleter;
+
+ public DocumentCommand(JavaPlugin plugin, String name, String permission) {
+ super(name, permission, "?format ?arg", marktr("saves documentation of the commands to a file"));
+ this.plugin = plugin;
+ tabCompleter = new AbstractTabCompleter() {
+ @Override
+ protected List getTabList(CommandSender commandSender, String term) {
+ return FORMATS;
+ }
+ };
+ }
+
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ if (args.length == 0 || args[0].equalsIgnoreCase("text")) {
+ int lineWidth = args.length > 1 && args[1].matches("\\d+") ? Integer.parseInt(args[1], 10) : 130;
+ return writeToFile(sender, new PlainTextCommandVisitor(lineWidth), getName() + ".txt");
+ } else if (args[0].equalsIgnoreCase("yml")) {
+ int groupDepth = args.length > 1 && args[1].matches("\\d+") ? Integer.parseInt(args[1], 10) : 2;
+ return writeToFile(sender, new PluginYamlCommandVisitor(groupDepth), getName() + ".yml");
+ }
+ return false;
+ }
+
+ private boolean writeToFile(CommandSender sender, DocumentWriter visitor, String filename) {
+ List commands = new ArrayList<>(plugin.getDescription().getCommands().keySet());
+ Collections.sort(commands);
+ for (String cmd : commands) {
+ PluginCommand pluginCommand = plugin.getCommand(cmd);
+ if (pluginCommand.getExecutor() instanceof Command) {
+ ((Command) pluginCommand.getExecutor()).accept(visitor);
+ }
+ }
+ File docFile = new File(plugin.getDataFolder(), filename);
+ try (FileOutputStream fos = new FileOutputStream(docFile);
+ PrintStream ps = new PrintStream(fos, true, StandardCharsets.UTF_8))
+ {
+ visitor.writeTo(ps);
+ sender.sendMessage(tr("Wrote documentation to {0}", docFile));
+ return true;
+ } catch (IOException e) {
+ sender.sendMessage(tr("\u00a74Error writing documentation: {0}", e.getMessage()));
+ }
+ return false;
+ }
+
+ @Override
+ public TabCompleter getTabCompleter() {
+ return tabCompleter;
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentWriter.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentWriter.java
new file mode 100644
index 000000000..e26cf2d5d
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/DocumentWriter.java
@@ -0,0 +1,10 @@
+package dk.lockfuglsang.minecraft.command;
+
+import java.io.PrintStream;
+
+/**
+ * Common interface for the DocumentCommand-visitors
+ */
+public interface DocumentWriter extends CommandVisitor {
+ void writeTo(PrintStream ps);
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PlainTextCommandVisitor.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PlainTextCommandVisitor.java
new file mode 100644
index 000000000..16b6b48d2
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PlainTextCommandVisitor.java
@@ -0,0 +1,66 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.util.FormatUtil;
+
+import java.io.PrintStream;
+import java.util.List;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * Simple visitor for generating plain-text documentation of an Command-hierarchy.
+ */
+public class PlainTextCommandVisitor extends RowCommandVisitor implements DocumentWriter {
+ private final int linewidth;
+
+ public PlainTextCommandVisitor(int linewidth) {
+ this.linewidth = linewidth;
+ }
+
+ public void writeTo(PrintStream out) {
+ int[] colWidths = new int[3];
+ for (Row row : getRows()) {
+ if (row != null) {
+ if (row.getCommand().length() > colWidths[0]) {
+ colWidths[0] = row.getCommand().length();
+ }
+ if (row.getPermission().length() > colWidths[1]) {
+ colWidths[1] = row.getPermission().length();
+ }
+ if (row.getDescription().length() > colWidths[2]) {
+ colWidths[2] = row.getDescription().length();
+ }
+ }
+ }
+ colWidths[0]++; // make room for the '/'
+ if (colWidths[0] + colWidths[1] + colWidths[2] > linewidth && colWidths[0] + colWidths[1] < linewidth) {
+ // truncate description column
+ colWidths[2] = linewidth - colWidths[0] - colWidths[1];
+ }
+ String rowFormat = "";
+ String separator = "";
+ for (int i = 0; i < colWidths.length; i++) {
+ if (i != 0) {
+ rowFormat += " | ";
+ separator += "-+-";
+ }
+ rowFormat += "%-" + colWidths[i] + "s";
+ separator += String.format("%" + colWidths[i] + "s", "").replaceAll(" ", "-");
+ }
+ out.println(String.format(rowFormat, tr("Command"), tr("Permission"), tr("Description")));
+ for (Row row : getRows()) {
+ if (row == null) {
+ out.println(separator);
+ } else {
+ String cmd = row.getCommand().isEmpty() ? "" : "/" + row.getCommand();
+ String description = row.getDescription();
+ List strings = FormatUtil.wordWrapStrict(description, colWidths[2]);
+ out.println(String.format(rowFormat, cmd, row.getPermission(), strings.size() > 0 ? strings.get(0) : ""));
+ for (int i = 1; i < strings.size(); i++) {
+ out.println(String.format(rowFormat, "", "", strings.get(i)));
+ }
+ }
+ }
+ }
+
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitor.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitor.java
new file mode 100644
index 000000000..2b34d29d6
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitor.java
@@ -0,0 +1,266 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.bukkit.command.CommandExecutor;
+
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Generates a yml-file following the syntax define in http://wiki.bukkit.org/Plugin_YAML
+ * @since 1.8
+ */
+public class PluginYamlCommandVisitor implements DocumentWriter {
+ private final int groupDepth;
+
+ List topLevel = new ArrayList<>();
+ PermissionNode rootNode;
+
+ PluginYamlCommandVisitor() {
+ this(2);
+ }
+
+ PluginYamlCommandVisitor(int groupDepth) {
+ this.groupDepth = groupDepth;
+ rootNode = new PermissionNode("");
+ }
+
+ public void writeTo(PrintStream out) {
+ out.println("commands:");
+ for (Command cmd : topLevel) {
+ out.println(" " + cmd.getName() + ":");
+ out.println(" description: '" + cmd.getDescription().replaceAll("'", "''") + "'");
+ if (cmd.getAliases().length > 1) {
+ String[] copy = new String[cmd.getAliases().length-1];
+ System.arraycopy(cmd.getAliases(), 1, copy, 0, copy.length);
+ out.println(" aliases: " + Arrays.toString(copy));
+ }
+ if (cmd.getPermission() != null && !cmd.getPermission().isEmpty()) {
+ out.println(" permission: " + cmd.getPermission());
+ }
+ }
+ out.println("permissions:");
+ out.println(" #");
+ out.println(" # Permission Groups");
+ out.println(" # =================");
+ for (PermissionNode node : rootNode.getRoots()) {
+ if (!node.permission.isEmpty()) {
+ if (node.permission.split("\\.").length > groupDepth) {
+ continue; // Skip
+ }
+ out.println(" " + node.permission + ".*:");
+ out.println(" children:");
+ for (String p : node.getChildPermissions()) {
+ out.println(" " + p + ": true");
+ }
+ out.println();
+ }
+ }
+ out.println(" #");
+ out.println(" # Permission Descriptions");
+ out.println(" # =======================");
+ for (PermissionNode node : rootNode.getLeafs()) {
+ if (!node.permission.isEmpty()) {
+ out.println(" " + node.permission + ":");
+ String desc = node.getDescription();
+ if (desc != null) {
+ out.println(" description: " + desc);
+ }
+ out.println();
+ }
+ }
+ }
+
+ private static String getCmdDescription(Command cmd) {
+ return "/" + getCommandPath(cmd) + " - " + cmd.getDescription();
+ }
+
+ private static String getCommandPath(Command cmd) {
+ String path = cmd.getParent() != null ? getCommandPath(cmd.getParent()) + " " : "";
+ return path + cmd.getName() + CompositeCommand.getParamsAsString(cmd);
+ }
+
+ @Override
+ public void visit(Command cmd) {
+ if (cmd instanceof CommandExecutor) {
+ topLevel.add(cmd);
+ }
+ String permission = getPermission(cmd);
+ rootNode.add(permission, cmd);
+ for (Map.Entry entry : cmd.getFeaturePermissions().entrySet()) {
+ rootNode.add(entry.getKey(), entry.getValue());
+ }
+ }
+
+ private String getPermission(Command cmd) {
+ if (cmd.getPermission() == null && cmd.getParent() != null) {
+ return getPermission(cmd.getParent());
+ } else if (cmd.getPermission() != null) {
+ return cmd.getPermission();
+ }
+ return null;
+ }
+
+ static class PermissionNode implements Comparable {
+ String permission;
+ String description; // Used for feature perms
+ List children = new ArrayList<>();
+ List commands = new ArrayList<>();
+
+ PermissionNode(String permission) {
+ this.permission = permission;
+ }
+
+ PermissionNode(String permission, Command cmd) {
+ this.permission = permission;
+ commands.add(cmd);
+ }
+
+ void add(String perm, Command cmd) {
+ if (permission.equalsIgnoreCase(perm) || perm == null) {
+ commands.add(cmd);
+ } else if (perm.startsWith(permission)) {
+ for (PermissionNode child : children) {
+ if (isSub(child, perm)) {
+ child.add(perm, cmd);
+ return;
+ }
+ }
+ String[] parts = !permission.isEmpty()
+ ? perm.substring(permission.length() + 1).split("\\.")
+ : perm.split("\\.");
+ String parentPerm = !permission.isEmpty()
+ ? permission + "." + parts[0]
+ : parts[0];
+ PermissionNode n = new PermissionNode(parentPerm);
+ children.add(n);
+ for (int i = 1; i < parts.length; i++) {
+ parentPerm += "." + parts[i];
+ PermissionNode newNode = new PermissionNode(parentPerm);
+ n.children.add(newNode);
+ n = newNode;
+ }
+ n.commands.add(cmd);
+ }
+ }
+
+ private boolean isSub(PermissionNode child, String perm) {
+ return perm.startsWith(child.permission)
+ && (child.permission.equalsIgnoreCase(perm) || (perm.length() > child.permission.length()
+ && perm.charAt(child.permission.length()) == '.')
+ );
+ }
+
+ void add(String perm, String description) {
+ if (permission.equalsIgnoreCase(perm) || perm == null) {
+ if (this.description == null) {
+ this.description = description;
+ }
+ } else if (perm.startsWith(permission)) {
+ for (PermissionNode child : children) {
+ if (isSub(child, perm)) {
+ child.add(perm, description);
+ return;
+ }
+ }
+ String[] parts = !permission.isEmpty()
+ ? perm.substring(permission.length() + 1).split("\\.")
+ : perm.split("\\.");
+ String parentPerm = !permission.isEmpty()
+ ? permission + "." + parts[0]
+ : parts[0];
+ PermissionNode n = new PermissionNode(parentPerm);
+ children.add(n);
+ for (int i = 1; i < parts.length; i++) {
+ parentPerm += "." + parts[i];
+ PermissionNode newNode = new PermissionNode(parentPerm);
+ n.children.add(newNode);
+ n = newNode;
+ }
+ n.description = description;
+ }
+ }
+
+ List getChildPermissions() {
+ List perms = new ArrayList<>();
+ for (PermissionNode child : children) {
+ perms.add(child.permission);
+ perms.addAll(child.getChildPermissions());
+ }
+ Collections.sort(perms);
+ return perms;
+ }
+
+ void addRoots(List roots) {
+ if (!children.isEmpty()) {
+ roots.add(this);
+ for (PermissionNode node : children) {
+ node.addRoots(roots);
+ }
+ }
+ }
+
+ List getRoots() {
+ List roots = new ArrayList<>();
+ addRoots(roots);
+ Collections.sort(roots);
+ return roots;
+ }
+
+ void addLeafs(List leafs) {
+ if (children.isEmpty() || !commands.isEmpty()) {
+ leafs.add(this);
+ }
+ for (PermissionNode node : children) {
+ node.addLeafs(leafs);
+ }
+ }
+
+ List getLeafs() {
+ List leafs = new ArrayList<>();
+ addLeafs(leafs);
+ Collections.sort(leafs);
+ return leafs;
+ }
+
+ String getDescription() {
+ if (description != null) {
+ return "'" + description + "'";
+ }
+ if (commands.size() == 1) {
+ return "'Grants access to " + getCmdDescription(commands.get(0)) + "'";
+ } else if (commands.size() > 1) {
+ String desc = "|" + System.lineSeparator();
+ desc += " Grants access to " + getCmdDescription(commands.get(0));
+ for (int i = 1; i < commands.size(); i++) {
+ desc += System.lineSeparator();
+ desc += " " + getCmdDescription(commands.get(i));
+ }
+ return desc;
+ }
+ return null;
+ }
+
+ public PermissionNode find(String perm) {
+ if (permission.equals(perm)) {
+ return this;
+ } else if (perm.startsWith(permission)) {
+ for (PermissionNode n : children) {
+ PermissionNode r = n.find(perm);
+ if (r != null) {
+ return r;
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public int compareTo(PermissionNode o) {
+ return permission.compareTo(o.permission);
+ }
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/README.md b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/README.md
new file mode 100644
index 000000000..10badd268
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/README.md
@@ -0,0 +1,73 @@
+# Commands
+
+This package contains a lot of convenience classes to easily create commands for use with Bukkit.
+
+It has an in-build help system, with pagination, and permission-control.
+
+# Usage
+
+## Getting Started
+
+To create a composite entry-level command, simply inherit from the `AbstractCommandExecutor` in your main plugin.
+
+```java
+class MyPlugin extends JavaPlugin {
+ @Override
+ public void onEnable() {
+ mycmd = new AbstractCommandExecutor("mycmd", "myplugin.perm.mycmd", "main myplugin command");
+ mycmd.add(new AbstractCommand("hello|h", "myplugin.perm.hello", "say hello to the player") {
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ sender.sendMessage(new String[]{
+ "Hello! and welcome " + sender.getName(),
+ "I was called with : " + alias,
+ "I had " + args.length + " arguments: " + Arrays.asList(args)
+ });
+ return true;
+ }
+ });
+ getCommand("mycmd", mycmd);
+ }
+}
+```
+
+The above code, will register 2 commands for the plugin.
+One is nested within the other.
+
+Help is automatically generated, so invoking `/mycmd` will show a list of possible options.
+Invoking `/mycmd h your momma` will output:
+
+```
+Hello! and welcome CONSOLE
+I was called with : h
+I had 2 arguments: [your, momma]
+```
+
+## Tab-Completion
+
+All CompositeCommands have automatic tab-completion of their sub-commands.
+
+If you want to have tab-completion of arguments, you can easily roll your own:
+
+```java
+monsterTab = new AbstractTabCompleter() {
+ @Override
+ protected List getTabList(CommandSender commandSender, String term) {
+ return Arrays.asList("animal", "monster", "villager");
+ }
+}
+```
+The above tab-completer will do filtering automatically.
+
+Tab-completers can be re-used, if they handle a named argument:
+
+```java
+ public class MyCmd extends CompositeCommand {
+ public MyCmd() {
+ super("mycmd", "perm.mycmd", "main command");
+ add(new AbstractCommand("list", "perm.list", "?monster", "lists a subset") { ... });
+ add(new AbstractCommand("spawn", "perm.spawn", "monster", "spawn a monster") { ... });
+ addTab("monster", monsterTab);
+ }
+ }
+```
\ No newline at end of file
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/RowCommandVisitor.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/RowCommandVisitor.java
new file mode 100644
index 000000000..3d50a933b
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/RowCommandVisitor.java
@@ -0,0 +1,90 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.bukkit.command.CommandExecutor;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * A visitor that simply gathers the complete command-hierarchy into a list of rows.
+ * @since 1.8
+ */
+class RowCommandVisitor implements CommandVisitor {
+ private final List rows = new ArrayList<>();
+
+ public List getRows() {
+ return rows;
+ }
+
+ @Override
+ public void visit(Command cmd) {
+ if (cmd instanceof CommandExecutor) {
+ rows.add(null); // separator
+ }
+ String commandPath = getCommandPath(cmd);
+ String alias = getAliases(cmd);
+ if (!alias.isEmpty()) {
+ String shortCmd = getShortestCmd(cmd);
+ int ix = commandPath.lastIndexOf(" " + shortCmd) + 1;
+ commandPath = commandPath.substring(0, ix) + cmd.getName() + alias + commandPath.substring(ix+shortCmd.length());
+ }
+ rows.add(new Row(commandPath, cmd.getDescription(), cmd.getPermission()));
+ List extraPerms = new ArrayList<>(cmd.getFeaturePermissions().keySet());
+ Collections.sort(extraPerms);
+ for (String p : extraPerms) {
+ rows.add(new Row(null, cmd.getFeaturePermissions().get(p), p));
+ }
+ }
+
+ private String getCommandPath(Command cmd) {
+ String path = cmd.getParent() != null ? getCommandPath(cmd.getParent()) + " " : "";
+ return path + getShortestCmd(cmd) + CompositeCommand.getParamsAsString(cmd);
+ }
+
+ private String getShortestCmd(Command cmd) {
+ String cmdName = cmd.getName();
+ for (String alias : cmd.getAliases()) {
+ if (alias.length() < cmdName.length()) {
+ cmdName = alias;
+ }
+ }
+ return cmdName;
+ }
+
+ private String getAliases(Command cmd) {
+ String aliases = "";
+ for (int i = 1; i < cmd.getAliases().length; i++) {
+ aliases += "|" + cmd.getAliases()[i];
+ }
+ return aliases;
+ }
+
+ static class Row {
+ private final String command;
+ private final String description;
+ private final String permission;
+
+ Row(String command, String description, String permission) {
+ this.command = command;
+ this.description = description;
+ this.permission = permission;
+ }
+
+ public String getCommand() {
+ return command != null ? command : "";
+ }
+
+ public String getDescription() {
+ return description != null ? description : "";
+ }
+
+ public String getPermission() {
+ return permission != null ? permission : "";
+ }
+
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/completion/AbstractTabCompleter.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/completion/AbstractTabCompleter.java
new file mode 100644
index 000000000..dd0a87e2b
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/command/completion/AbstractTabCompleter.java
@@ -0,0 +1,44 @@
+package dk.lockfuglsang.minecraft.command.completion;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Common ancestor of the TabCompleters.
+ * Uses the Template Pattern for sub-classes.
+ */
+public abstract class AbstractTabCompleter implements TabCompleter {
+
+ abstract protected List getTabList(CommandSender commandSender, String term);
+
+ @Override
+ public List onTabComplete(CommandSender commandSender, Command command, String alias, String[] args) {
+ String term = args.length > 0 ? args[args.length-1] : "";
+ return filter(getTabList(commandSender, term), term);
+ }
+
+ public static List filter(List list, String prefix) {
+ Set filtered = new LinkedHashSet<>();
+ if (list != null) {
+ Collections.sort(list);
+ String lowerPrefix = prefix.toLowerCase();
+ for (String test : list) {
+ if (test.toLowerCase().startsWith(lowerPrefix)) {
+ filtered.add(test);
+ }
+ }
+ }
+ if (filtered.size() > 20) {
+ return new ArrayList<>(filtered).subList(0, 20);
+ }
+ return new ArrayList<>(filtered);
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/FileUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/FileUtil.java
new file mode 100644
index 000000000..25591fc5c
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/FileUtil.java
@@ -0,0 +1,281 @@
+package dk.lockfuglsang.minecraft.file;
+
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Common file-utilities.
+ */
+public enum FileUtil {
+ ;
+ private static final Logger log = Logger.getLogger(FileUtil.class.getName());
+ private static final Collection alwaysOverwrite = new ArrayList<>();
+ private static final Collection neverOverwrite = new ArrayList<>();
+ private static final Map configFiles = new ConcurrentHashMap<>();
+ private static Locale locale = Locale.getDefault();
+ private static File dataFolder;
+
+ public static void setAlwaysOverwrite(String... configs) {
+ for (String s : configs) {
+ if (!alwaysOverwrite.contains(s)) {
+ alwaysOverwrite.add(s);
+ }
+ }
+ }
+
+ public static void readConfig(FileConfiguration config, File file) {
+ if (file == null) {
+ log.log(Level.INFO, "No config file found, it will be created");
+ return;
+ }
+ File configFile = file;
+ File localeFile = new File(configFile.getParentFile(), getLocaleName(file.getName()));
+ if (localeFile.exists() && localeFile.canRead()) {
+ configFile = localeFile;
+ }
+ if (!configFile.exists()) {
+ log.log(Level.INFO, "No " + configFile + " found, it will be created");
+ return;
+ }
+ try (Reader rdr = new InputStreamReader(new FileInputStream(configFile), StandardCharsets.UTF_8)) {
+ config.load(rdr);
+ } catch (InvalidConfigurationException e) {
+ log.log(Level.SEVERE, "Unable to read config file " + configFile, e);
+ if (configFile.exists()) {
+ try {
+ Files.copy(Paths.get(configFile.toURI()), Paths.get(configFile.getParent(), configFile.getName() + ".err"), StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e1) {
+ // Ignore - we tried...
+ }
+ }
+ } catch (IOException e) {
+ log.log(Level.SEVERE, "Unable to read config file " + configFile, e);
+ }
+ }
+
+ public static void readConfig(FileConfiguration config, InputStream inputStream) {
+ if (inputStream == null) {
+ return;
+ }
+ try (Reader rdr = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
+ config.load(rdr);
+ } catch (InvalidConfigurationException | IOException e) {
+ log.log(Level.SEVERE, "Unable to read configuration", e);
+ }
+ }
+
+ public static String getBasename(String file) {
+ String[] lastPart = file.split("([/\\\\])");
+ file = lastPart[lastPart.length - 1];
+ if (file != null && file.lastIndexOf('.') != -1) {
+ return file.substring(0, file.lastIndexOf('.'));
+ }
+ return file;
+ }
+
+ public static String getExtension(String fileName) {
+ if (fileName != null && !fileName.isEmpty()) {
+ return fileName.substring(getBasename(fileName).length() + 1);
+ }
+ return "";
+ }
+
+ private static File getDataFolder() {
+ return dataFolder != null ? dataFolder : new File(".");
+ }
+
+ /**
+ * System-encoding agnostic config-reader
+ * Reads and returns the configuration found in:
+ *
+ * a) the datafolder
+ *
+ * a.1) if a config named "config_en.yml" exists - that is read.
+ *
+ * a.2) otherwise "config.yml" is read (created if need be).
+ *
+ * b) if the version differs from the same resource on the classpath
+ *
+ * b.1) all nodes in the jar-file-version is merged* into the local-file
+ *
+ * b.2) unless the configName is in the allwaysOverwrite - then the jar-version wins
+ *
+ * *merged: using data-conversion of special nodes.
+ *
+ */
+ public static FileConfiguration getYmlConfiguration(String configName) {
+ // Caching, for your convenience! (and a bigger memory print!)
+
+ if (!configFiles.containsKey(configName)) {
+ FileConfiguration config = new YamlConfiguration();
+ try {
+ // read from datafolder!
+ File configFile = getConfigFile(configName);
+ YamlConfiguration configJar = new YamlConfiguration();
+ readConfig(config, configFile);
+ readConfig(configJar, getResource(configName));
+ if (!configFile.exists() || config.getInt("version", 0) < configJar.getInt("version", 0)) {
+ if (configFile.exists()) {
+ if (neverOverwrite.contains(configName)) {
+ configFiles.put(configName, config);
+ return config;
+ }
+ File backupFolder = new File(getDataFolder(), "backup");
+ backupFolder.mkdirs();
+ String bakFile = String.format("%1$s-%2$tY%2$tm%2$td-%2$tH%2$tM.yml", getBasename(configName), new Date());
+ log.log(Level.INFO, "Moving existing config " + configName + " to backup/" + bakFile);
+ Files.move(Paths.get(configFile.toURI()),
+ Paths.get(new File(backupFolder, bakFile).toURI()),
+ StandardCopyOption.REPLACE_EXISTING);
+ if (alwaysOverwrite.contains(configName)) {
+ FileUtil.copy(getResource(configName), configFile);
+ config = configJar;
+ } else {
+ config = mergeConfig(configJar, config);
+ config.save(configFile);
+ config.load(configFile);
+ }
+ } else {
+ config = mergeConfig(configJar, config);
+ config.save(configFile);
+ config.load(configFile);
+ }
+ }
+ } catch (Exception e) {
+ log.log(Level.SEVERE, "Unable to handle config-file " + configName, e);
+ }
+ configFiles.put(configName, config);
+ }
+ return configFiles.get(configName);
+ }
+
+ private static InputStream getResource(String configName) {
+ String resourceName = getLocaleName(configName);
+ ClassLoader loader = FileUtil.class.getClassLoader();
+ InputStream resourceAsStream = loader.getResourceAsStream(resourceName);
+ if (resourceAsStream != null) {
+ return resourceAsStream;
+ }
+ return loader.getResourceAsStream(configName);
+ }
+
+ private static String getLocaleName(String fileName) {
+ String baseName = getBasename(fileName);
+ return baseName + "_" + locale + fileName.substring(baseName.length());
+ }
+
+ public static File getConfigFile(String configName) {
+ File file = new File(getDataFolder(), getLocaleName(configName));
+ if (file.exists()) {
+ return file;
+ }
+ return new File(getDataFolder(), configName);
+ }
+
+ public static void copy(InputStream stream, File file) throws IOException {
+ if (stream == null || file == null) {
+ throw new IOException("Invalid resource for " + file);
+ }
+ Files.copy(stream, Paths.get(file.toURI()), StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ /**
+ * Merges the important keys from src to destination.
+ *
+ * @param src The source (containing the new values).
+ * @param dest The destination (containing old-values).
+ */
+ private static FileConfiguration mergeConfig(FileConfiguration src, FileConfiguration dest) {
+ int existing = dest.getInt("version");
+ int version = src.getInt("version", existing);
+ dest.setDefaults(src);
+ dest.options().copyDefaults(true);
+ dest.set("version", version);
+ removeExcludes(dest);
+ moveNodes(src, dest);
+ replaceDefaults(src, dest);
+ return dest;
+ }
+
+ /**
+ * Removes nodes from dest.defaults, that are specifically excluded in the config
+ */
+ private static void removeExcludes(FileConfiguration dest) {
+ List keys = dest.getStringList("merge-ignore");
+ for (String key : keys) {
+ requireNonNull(dest.getDefaults()).set(key, null);
+ }
+ }
+
+ private static void replaceDefaults(FileConfiguration src, FileConfiguration dest) {
+ ConfigurationSection forceSection = src.getConfigurationSection("force-replace");
+ if (forceSection != null) {
+ for (String key : forceSection.getKeys(true)) {
+ Object def = forceSection.get(key, null);
+ Object value = dest.get(key, def);
+ Object newDef = src.get(key, null);
+ if (def != null && def.equals(value)) {
+ dest.set(key, newDef);
+ }
+ }
+ }
+ dest.set("force-replace", null);
+ requireNonNull(dest.getDefaults()).set("force-replace", null);
+ }
+
+ private static void moveNodes(FileConfiguration src, FileConfiguration dest) {
+ ConfigurationSection moveSection = src.getConfigurationSection("move-nodes");
+ if (moveSection != null) {
+ List keys = new ArrayList<>(moveSection.getKeys(true));
+ Collections.reverse(keys); // Depth first
+ for (String key : keys) {
+ if (moveSection.isString(key)) {
+ String srcPath = key;
+ String tgtPath = moveSection.getString(key, key);
+ Object value = dest.get(srcPath);
+ if (value != null) {
+ dest.set(tgtPath, value);
+ dest.set(srcPath, null);
+ }
+ } else if (moveSection.isConfigurationSection(key)) {
+ // Check to see if dest section should be nuked...
+ if (dest.isConfigurationSection(key) && dest.getConfigurationSection(key).getKeys(false).isEmpty()) {
+ dest.set(key, null);
+ }
+ }
+ }
+ }
+ dest.set("move-nodes", null);
+ requireNonNull(dest.getDefaults()).set("move-nodes", null);
+ }
+
+ public static void setDataFolder(File dataFolder) {
+ FileUtil.dataFolder = dataFolder;
+ configFiles.clear();
+ }
+
+ public static void setLocale(Locale loc) {
+ locale = loc != null ? loc : locale;
+ }
+
+ public static void reload() {
+ for (Map.Entry e : configFiles.entrySet()) {
+ File configFile = new File(getDataFolder(), e.getKey());
+ readConfig(e.getValue(), configFile);
+ }
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/README.md b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/README.md
new file mode 100644
index 000000000..85a8ef49d
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/file/README.md
@@ -0,0 +1,47 @@
+# File Utilities
+
+The `FileUtil` class simplifies resource reading for Bukkit plugins.
+
+## Features
+
+The primary usage of the FileUtil, is to read yml-files from the plugin-datafolder.
+
+The utility supports the following features:
+
+ * Reading files in UTF-8, regardless of system-encoding.
+ * Support merging yml files from classpath (jar) with plugin files.
+ * Support for `version`ing of individual yml files.
+ * Support for localization of configuration files.
+ * Support for data-conversion on merges.
+ * Support caching of configuration objects (only read from disk once).
+
+## Usage
+
+```java
+ @Override
+ void onEnable() {
+ FileUtil.setDataFolder(getDataFolder()); // init plugin-data-folder
+ FileUtil.setLocale(new Locale("en")); // set locale used in locating translated resources
+
+ YmlConfiguration config = FileUtil.getYmlConfiguration("config.yml");
+ if (config.getBoolean("feature.enabled", true)) {
+ // do feature stuff
+ }
+ }
+```
+
+The `config` in the above example is created the following way:
+ * Read `config.yml` from the data-folder
+ * if a `config_en.yml` exists - read it
+ * else if a `config.yml` exist - read it
+ * Read `config.yml` from the jar-file (classpath)
+ * if a 'config_en.yml` exists - read it
+ * else if a `config.yml` exsits - read it
+ * Compare `version` read from both configs
+ * if `config.yml` is listed in `allwaysOverwrite` - just use the jar-file
+ * else if jar-version > file-version or file-version does not exist
+ * merge nodes from jar into config - preserving node-comments
+ * ignore nodes listed in `merge-ignore` from the existing config-file
+ * move nodes listed in `move-nodes` from the jar-file
+ * replace nodes listed in `replace-nodes` from the jar-file, if they have default values
+ * set `version` to the jar-file version
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/BlockRequirement.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/BlockRequirement.java
new file mode 100644
index 000000000..0d243a8dd
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/BlockRequirement.java
@@ -0,0 +1,9 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.bukkit.block.data.BlockData;
+
+// This class should ideally be located in package us.talabrek.ultimateskyblock.challenge. However, it is not possible
+// to move there as it is required by ItemStackUtil in this module. This is a limitation of the current design.
+// The parsing logic should eventually be moved to the uSkyBlock-Core module.
+public record BlockRequirement(BlockData type, int amount) {
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/FormatUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/FormatUtil.java
new file mode 100644
index 000000000..a980bccb8
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/FormatUtil.java
@@ -0,0 +1,171 @@
+package dk.lockfuglsang.minecraft.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Format utility
+ */
+public enum FormatUtil {;
+ private static final Pattern FORMATTING = Pattern.compile("^.*(?(\u00a7[0-9a-fklmor])+).*");
+ public static String stripFormatting(String format) {
+ if (format == null || format.trim().isEmpty()) {
+ return "";
+ }
+ return format.replaceAll("(\u00a7|&)[0-9a-fklmor]", "");
+ }
+
+ public static String normalize(String format) {
+ if (format == null || format.trim().isEmpty()) {
+ return "";
+ }
+ return format.replaceAll("(\u00a7|&)([0-9a-fklmor])", "\u00a7$2");
+ }
+
+ public static List wordWrap(String s, int lineSize) {
+ return wordWrap(s, lineSize, lineSize);
+ }
+
+ /**
+ * Wraps the string rather lazyly around the linesize (over).
+ *
+ * I.e.
+ *
+ * this is a line of words
+ *
+ * Will break into the following with linesize of 11:
+ *
+ * this is a line
+ * of words
+ *
+ * Note that the first line is longer than 11 (14).
+ */
+ public static List wordWrap(String s, int firstSegment, int lineSize) {
+ String format = getFormat(s);
+ if (format == null || !s.startsWith(format)) {
+ format = "";
+ }
+ List words = new ArrayList<>();
+ int numChars = firstSegment;
+ int ix = 0;
+ int jx = 0;
+ while (ix < s.length()) {
+ ix = s.indexOf(' ', ix+1);
+ if (ix != -1) {
+ String subString = s.substring(jx, ix).trim();
+ String f = getFormat(subString);
+ int chars = stripFormatting(subString).length() + 1; // remember the space
+ if (chars >= numChars) {
+ if (f != null) {
+ format = f;
+ }
+ if (!subString.isEmpty()) {
+ words.add(withFormat(format, subString));
+ numChars = lineSize;
+ jx = ix + 1;
+ }
+ }
+ } else {
+ break;
+ }
+ }
+ words.add(withFormat(format, s.substring(jx).trim()));
+ return words;
+ }
+
+ public static List wordWrapStrict(String s, int lineLength) {
+ List lines = new ArrayList<>();
+ String format = getFormat(s);
+ if (format == null || !s.startsWith(format)) {
+ format = "";
+ }
+ String[] words = s.split(" ");
+ String line = "";
+ for (String word: words) {
+ String test = stripFormatting(line + " " + word).trim();
+ if (test.length() <= lineLength) {
+ // add word
+ line += (line.isEmpty() ? "" : " ") + word;
+ } else if (line.isEmpty() || stripFormatting(word).length() > lineLength) {
+ // add word truncated
+ String f = getFormat(word);
+ String strip = stripFormatting(word);
+ do {
+ int len = Math.min(strip.length(), lineLength-line.length()-1);
+ lines.add(withFormat(format, line + (line.isEmpty() ? "" : " ") + strip.substring(0, len)));
+ strip = strip.substring(len);
+ if (f != null) {
+ format = f;
+ }
+ } while (strip.length() > lineLength);
+ line = strip;
+ } else {
+ // add line, then start a new
+ lines.add(withFormat(format, line));
+ String f = getFormat(line);
+ if (f != null) {
+ format = f;
+ }
+ line = word;
+ }
+ }
+ if (!line.isEmpty()) {
+ lines.add(withFormat(format, line));
+ }
+ return lines;
+ }
+
+ private static String withFormat(String format, String subString) {
+ String sf = null;
+ if (!subString.startsWith("\u00a7")) {
+ sf = format + subString;
+ } else {
+ sf = subString;
+ }
+ return sf;
+ }
+
+ private static String getFormat(String s) {
+ Matcher m = FORMATTING.matcher(s);
+ String format = null;
+ if (m.matches() && m.group("format") != null) {
+ format = m.group("format");
+ }
+ return format;
+ }
+
+ public static List prefix(List list, String prefix) {
+ List prefixed = new ArrayList<>(list.size());
+ for (String s : list) {
+ prefixed.add(prefix + s);
+ }
+ return prefixed;
+ }
+
+ public static String camelcase(String name) {
+ if (name == null || name.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String part : name.split("[ _]")) {
+ sb.append(Character.toUpperCase(part.charAt(0)));
+ sb.append(part.substring(1).toLowerCase());
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Escapes formatting by "denormalizing" back to using & instead of §.
+ * @param formatString A formatstring (formerly normalized).
+ * @return A non-format string using & instead of §.
+ * @since 1.10
+ */
+ public static String escape(String formatString) {
+ String escaped = normalize(formatString);
+ escaped = escaped
+ .replaceAll("\u00a7", "&");
+ return escaped;
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemRequirement.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemRequirement.java
new file mode 100644
index 000000000..bce668c73
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemRequirement.java
@@ -0,0 +1,69 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.bukkit.inventory.ItemStack;
+
+// This class should ideally be located in package us.talabrek.ultimateskyblock.challenge. However, it is not possible
+// to move there as it is required by ItemStackUtil in this module. This is a limitation of the current design.
+// The parsing logic should eventually be moved to the uSkyBlock-Core module.
+public record ItemRequirement(ItemStack type, int amount, Operator operator, double increment) {
+
+ public Integer amountForRepetitions(int repetitions) {
+ return (int) Math.floor(operator().apply(amount(), increment(), repetitions));
+ }
+
+ public enum Operator {
+ NONE("", 0.0) {
+ @Override
+ public double apply(double value, double increment, int repetitions) {
+ return value;
+ }
+ },
+ ADD("+", 0.0) {
+ @Override
+ public double apply(double value, double increment, int repetitions) {
+ return value + increment * repetitions;
+ }
+ },
+ SUBTRACT("-", 0.0) {
+ @Override
+ public double apply(double value, double increment, int repetitions) {
+ return value - increment * repetitions;
+ }
+ },
+ MULTIPLY("*", 1.0) {
+ @Override
+ public double apply(double value, double increment, int repetitions) {
+ return value * Math.pow(increment, repetitions);
+ }
+ },
+ DIVIDE("/", 1.0) {
+ @Override
+ public double apply(double value, double increment, int repetitions) {
+ return value / Math.pow(increment, repetitions);
+ }
+ };
+
+ private final String symbol;
+ private final double neutralElement;
+
+ Operator(String symbol, double neutralElement) {
+ this.symbol = symbol;
+ this.neutralElement = neutralElement;
+ }
+
+ public double getNeutralElement() {
+ return neutralElement;
+ }
+
+ public abstract double apply(double value, double increment, int repetitions);
+
+ public static Operator fromSymbol(String symbol) {
+ for (Operator operator : values()) {
+ if (operator.symbol.equals(symbol)) {
+ return operator;
+ }
+ }
+ throw new IllegalArgumentException("Unknown operator: " + symbol);
+ }
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemStackUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemStackUtil.java
new file mode 100644
index 000000000..62c564467
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/ItemStackUtil.java
@@ -0,0 +1,328 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.block.data.BlockData;
+import org.bukkit.enchantments.Enchantment;
+import org.bukkit.inventory.ItemFlag;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+/**
+ * Conversion to ItemStack from strings.
+ */
+public enum ItemStackUtil {
+ ;
+ private static final Pattern ITEM_AMOUNT_PROBABILITY_PATTERN = Pattern.compile(
+ "(\\{p=(?0\\.\\d+)})?(?(minecraft:)?[0-9A-Za-z_]+(\\[.*])?):(?\\d+)"
+ );
+ private static final Pattern ITEM_TYPE_PATTERN = Pattern.compile(
+ "(?(minecraft:)?[0-9A-Za-z_]+(\\[.*])?)"
+ );
+ private static final Pattern ITEM_REQUIREMENT_PATTERN = Pattern.compile(
+ "(?(minecraft:)?[0-9A-Za-z_]+(\\[.*])?):(?\\d+)(;(?[-+*^/])(?\\d+(.\\d+)?))?"
+ );
+ private static final Pattern BLOCK_REQUIREMENT_PATTERN = Pattern.compile(
+ "(?(minecraft:)?[0-9A-Za-z_]+(\\[.*])?):(?\\d+)"
+ );
+
+ @NotNull
+ public static BlockRequirement createBlockRequirement(@NotNull String specification) {
+ Matcher matcher = BLOCK_REQUIREMENT_PATTERN.matcher(specification);
+ if (matcher.matches()) {
+ BlockData itemStack = getBlockType(matcher);
+ int amount = Integer.parseInt(matcher.group("amount"));
+ return new BlockRequirement(itemStack, amount);
+ } else {
+ throw new IllegalArgumentException("Invalid item requirement: '" + specification + "'");
+ }
+ }
+
+ @NotNull
+ private static BlockData getBlockType(@NotNull Matcher matcher) {
+ String type = matcher.group("type");
+ return Bukkit.createBlockData(type.toLowerCase(Locale.ROOT));
+ }
+
+ @NotNull
+ public static ItemRequirement createItemRequirement(@NotNull String specification) {
+ Matcher matcher = ITEM_REQUIREMENT_PATTERN.matcher(specification);
+ if (matcher.matches()) {
+ ItemStack itemStack = getItemType(matcher);
+ int amount = Integer.parseInt(matcher.group("amount"));
+ ItemRequirement.Operator operator = matcher.group("op") != null ?
+ ItemRequirement.Operator.fromSymbol(matcher.group("op")) : ItemRequirement.Operator.NONE;
+ double increment = matcher.group("inc") != null ?
+ Double.parseDouble(matcher.group("inc")) : operator.getNeutralElement();
+ return new ItemRequirement(itemStack, amount, operator, increment);
+ } else {
+ throw new IllegalArgumentException("Invalid item requirement: '" + specification + "'");
+ }
+ }
+
+ @NotNull
+ public static List createItemsWithProbability(@NotNull List items) {
+ List itemsWithProbability = new ArrayList<>();
+ for (String reward : items) {
+ Matcher matcher = ITEM_AMOUNT_PROBABILITY_PATTERN.matcher(reward);
+ if (matcher.matches()) {
+ double probability = matcher.group("prob") != null ?
+ Double.parseDouble(matcher.group("prob")) : 1.0;
+ ItemStack itemStack = getItemType(matcher);
+ int amount = Integer.parseInt(matcher.group("amount"));
+ itemStack.setAmount(amount);
+ itemsWithProbability.add(new ItemProbability(probability, itemStack));
+ } else {
+ throw new IllegalArgumentException("Unknown item: '" + reward + "' in '" + items + "'");
+ }
+ }
+ return itemsWithProbability;
+ }
+
+ @NotNull
+ private static ItemStack getItemType(@NotNull Matcher matcher) {
+ String type = matcher.group("type");
+ return Bukkit.getItemFactory().createItemStack(type.toLowerCase(Locale.ROOT));
+ }
+
+ // used for parsing challenge rewards and starter chest items
+ @NotNull
+ public static List createItemList(@NotNull List items) {
+ List itemList = new ArrayList<>();
+ for (String reward : items) {
+ if (reward != null && !reward.isEmpty()) {
+ itemList.add(createItemStackAmount(reward));
+ }
+ }
+ return itemList;
+ }
+
+ @NotNull
+ private static ItemStack createItemStackAmount(@NotNull String reward) {
+ Matcher matcher = ITEM_AMOUNT_PROBABILITY_PATTERN.matcher(reward);
+ if (matcher.matches()) {
+ ItemStack itemStack = getItemType(matcher);
+ int amount = Integer.parseInt(matcher.group("amount"));
+ itemStack.setAmount(amount);
+ return itemStack;
+ } else {
+ throw new IllegalArgumentException("Unknown item: '" + reward + "'");
+ }
+ }
+
+ public static ItemStack[] createItemArray(List items) {
+ return items != null ? items.toArray(new ItemStack[0]) : new ItemStack[0];
+ }
+
+ public static ItemStack createItemStack(String displayItem) {
+ return createItemStack(displayItem, null, null);
+ }
+
+ public static ItemStack createItemStack(@NotNull String displayItem, @Nullable String name, @Nullable String description) {
+ Matcher matcher = ITEM_TYPE_PATTERN.matcher(displayItem);
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("Invalid item " + displayItem + " supplied!");
+ }
+ ItemStack itemStack = getItemType(matcher);
+ ItemMeta meta = itemStack.getItemMeta();
+ if (meta != null) {
+ if (name != null) {
+ meta.setDisplayName(FormatUtil.normalize(name));
+ }
+ List lore = new ArrayList<>();
+ if (description != null) {
+ lore.addAll(FormatUtil.wordWrap(FormatUtil.normalize(description), 30, 30));
+ }
+ meta.setLore(lore);
+ itemStack.setItemMeta(meta);
+ }
+ return itemStack;
+ }
+
+ public static @NotNull List clone(@NotNull List items) {
+ return items.stream().map(ItemStack::clone).toList();
+ }
+
+ public static Builder builder(ItemStack stack) {
+ return new Builder(stack);
+ }
+
+ public static String asString(ItemStack item) {
+ var itemType = item.getType().getKey().toString();
+ var itemMeta = item.getItemMeta();
+ if (itemMeta != null) {
+ var componentString = itemMeta.getAsComponentString();
+ if (!componentString.isEmpty() && !componentString.equals("[]")) {
+ itemType += componentString;
+ }
+ }
+ return itemType + ":" + item.getAmount();
+ }
+
+ public static String asShortString(List items) {
+ List shorts = new ArrayList<>();
+ for (ItemStack item : items) {
+ shorts.add(asShortString(item));
+ }
+ return "[" + String.join(", ", shorts) + "]";
+ }
+
+ public static String asShortString(ItemStack item) {
+ if (item == null) {
+ return "";
+ }
+ return item.getAmount() > 1
+ ? tr("\u00a7f{0}x \u00a77{1}", item.getAmount(), getItemName(item))
+ : tr("\u00a77{0}", getItemName(item));
+ }
+
+ @NotNull
+ public static ItemStack asDisplayItem(@NotNull ItemStack item) {
+ ItemStack copy = new ItemStack(item);
+ ItemMeta itemMeta = copy.getItemMeta();
+ if (itemMeta != null) {
+ itemMeta.addItemFlags(ItemFlag.values());
+ }
+ copy.setItemMeta(itemMeta);
+ return copy;
+ }
+
+ @Contract("null -> null; !null -> !null")
+ @Nullable
+ public static String getItemName(ItemStack stack) {
+ if (stack == null) {
+ return null;
+ }
+ var itemMeta = stack.getItemMeta();
+ if (itemMeta != null && itemMeta.hasDisplayName() && !itemMeta.getDisplayName().trim().isEmpty()) {
+ return stack.getItemMeta().getDisplayName();
+ }
+ return tr(FormatUtil.camelcase(stack.getType().name()).replaceAll("([A-Z])", " $1").trim());
+ }
+
+ @NotNull
+ public static String getBlockName(@NotNull BlockData block) {
+ return tr(FormatUtil.camelcase(block.getMaterial().name()).replaceAll("([A-Z])", " $1").trim());
+ }
+
+ @Contract(pure = true)
+ @NotNull
+ public static ItemStack[] asValidItemStacksWithAmount(@NotNull Map typesWithAmount) {
+ return typesWithAmount.entrySet().stream().flatMap(entry -> {
+ ItemStack requiredType = entry.getKey();
+ int requiredAmount = entry.getValue();
+ List result = new ArrayList<>();
+ int remaining = requiredAmount;
+ while (remaining > 0) {
+ ItemStack item = requiredType.clone();
+ item.setAmount(Math.min(remaining, item.getMaxStackSize()));
+ remaining -= item.getAmount();
+ result.add(item);
+ }
+ return result.stream();
+ }).toArray(ItemStack[]::new);
+ }
+
+ /**
+ * Builder for ItemStack
+ */
+ public static class Builder {
+ private final ItemStack itemStack;
+
+ public Builder(ItemStack itemStack) {
+ this.itemStack = itemStack != null ? itemStack.clone() : new ItemStack(Material.AIR);
+ }
+
+ public Builder type(Material mat) {
+ itemStack.setType(mat);
+ return this;
+ }
+
+ public Builder amount(int amount) {
+ itemStack.setAmount(amount);
+ return this;
+ }
+
+ public Builder displayName(String name) {
+ ItemMeta itemMeta = itemStack.getItemMeta();
+ itemMeta.setDisplayName(name);
+ itemStack.setItemMeta(itemMeta);
+ return this;
+ }
+
+ public Builder enchant(Enchantment enchantment, int level) {
+ ItemMeta itemMeta = itemStack.getItemMeta();
+ itemMeta.addEnchant(enchantment, level, false);
+ itemStack.setItemMeta(itemMeta);
+ return this;
+ }
+
+ public Builder select(boolean b) {
+ return b ? select() : deselect();
+ }
+
+ public Builder select() {
+ return enchant(Enchantment.PROTECTION, 1).add(ItemFlag.HIDE_ENCHANTS);
+ }
+
+ public Builder deselect() {
+ return remove(Enchantment.PROTECTION).remove(ItemFlag.HIDE_ENCHANTS);
+ }
+
+ public Builder add(ItemFlag... flags) {
+ ItemMeta meta = itemStack.getItemMeta();
+ meta.addItemFlags(flags);
+ itemStack.setItemMeta(meta);
+ return this;
+ }
+
+ public Builder remove(ItemFlag... flags) {
+ ItemMeta meta = itemStack.getItemMeta();
+ meta.removeItemFlags(flags);
+ itemStack.setItemMeta(meta);
+ return this;
+ }
+
+ private Builder remove(Enchantment enchantment) {
+ ItemMeta itemMeta = itemStack.getItemMeta();
+ itemMeta.removeEnchant(enchantment);
+ itemStack.setItemMeta(itemMeta);
+ return this;
+ }
+
+ public Builder lore(String lore) {
+ return lore(Collections.singletonList(FormatUtil.normalize(lore)));
+ }
+
+ public Builder lore(List lore) {
+ ItemMeta itemMeta = itemStack.getItemMeta();
+ if (itemMeta != null) {
+ if (itemMeta.getLore() == null) {
+ itemMeta.setLore(lore);
+ } else {
+ List oldLore = itemMeta.getLore();
+ oldLore.addAll(lore);
+ itemMeta.setLore(oldLore);
+ }
+ itemStack.setItemMeta(itemMeta);
+ }
+ return this;
+ }
+
+ public ItemStack build() {
+ return itemStack;
+ }
+ }
+
+ public record ItemProbability(double probability, ItemStack item) {
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/LocationUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/LocationUtil.java
new file mode 100644
index 000000000..4a0ada285
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/LocationUtil.java
@@ -0,0 +1,74 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Static convenience class for location methods.
+ */
+public enum LocationUtil {
+ ;
+ private static final Pattern LOCATION_PATTERN = Pattern.compile("((?[^:/]+)[:/])?(?[\\-0-9\\.]+),(?[\\-0-9\\.]+),(?[\\-0-9\\.]+)(:(?[\\-0-9\\.]+):(?[\\-0-9\\.]+))?");
+
+ public static String asString(Location loc) {
+ if (loc == null) {
+ return null;
+ }
+ String s = "";
+ if (loc.getWorld() != null && loc.getWorld().getName() != null) {
+ s += loc.getWorld().getName() + ":";
+ }
+ s += String.format(Locale.ENGLISH, "%.2f,%.2f,%.2f", loc.getX(), loc.getY(), loc.getZ());
+ if (loc.getYaw() != 0f || loc.getPitch() != 0f) {
+ s += String.format(Locale.ENGLISH, ":%.2f:%.2f", loc.getYaw(), loc.getPitch());
+ }
+ return s;
+ }
+
+ /**
+ * Convenience method for when a location is needed as a yml key.
+ */
+ public static String asKey(Location loc) {
+ return asString(loc).replaceAll(":", "/").replaceAll("\\.", "_");
+ }
+
+ public static Location fromString(String locString) {
+ if (locString == null || locString.isEmpty()) {
+ return null;
+ }
+ Matcher m = LOCATION_PATTERN.matcher(locString.replaceAll("_", "\\."));
+ if (m.matches()) {
+ return new Location(Bukkit.getWorld(m.group("world")),
+ Double.parseDouble(m.group("x")),
+ Double.parseDouble(m.group("y")),
+ Double.parseDouble(m.group("z")),
+ m.group("yaw") != null ? Float.parseFloat(m.group("yaw")) : 0,
+ m.group("pitch") != null ? Float.parseFloat(m.group("pitch")) : 0
+ );
+ }
+ return null;
+ }
+
+ public static Location centerOnBlock(Location loc) {
+ if (loc == null) {
+ return null;
+ }
+ return new Location(loc.getWorld(),
+ loc.getBlockX() + 0.5, loc.getBlockY() + 0.1, loc.getBlockZ() + 0.5,
+ loc.getYaw(), loc.getPitch());
+ }
+
+ public static Location centerInBlock(Location loc) {
+ if (loc == null) {
+ return null;
+ }
+ return new Location(loc.getWorld(),
+ loc.getBlockX() + 0.5, loc.getBlockY() + 0.5, loc.getBlockZ() + 0.5,
+ loc.getYaw(), loc.getPitch());
+ }
+
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/TimeUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/TimeUtil.java
new file mode 100644
index 000000000..30afe24ad
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/TimeUtil.java
@@ -0,0 +1,71 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static dk.lockfuglsang.minecraft.po.I18nUtil.tr;
+
+public enum TimeUtil {
+ ;
+ private static final Pattern TIME_PATTERN = Pattern.compile("((?[0-9]+)d)?\\s*((?[0-9]+)h)?\\s*((?[0-9]+)m)?\\s*((?[0-9]+)s)?\\s*((?[0-9]+)ms)?");
+
+ public static @Nullable Duration stringAsDuration(@NotNull String specification) {
+ Matcher matcher = TIME_PATTERN.matcher(specification);
+ if (matcher.matches()) {
+ Duration result = Duration.ZERO;
+ if (matcher.group("d") != null) {
+ result = result.plusDays(Long.parseLong(matcher.group("d")));
+ }
+ if (matcher.group("h") != null) {
+ result = result.plusHours(Long.parseLong(matcher.group("h")));
+ }
+ if (matcher.group("m") != null) {
+ result = result.plusMinutes(Long.parseLong(matcher.group("m")));
+ }
+ if (matcher.group("s") != null) {
+ result = result.plusSeconds(Long.parseLong(matcher.group("s")));
+ }
+ if (matcher.group("ms") != null) {
+ result = result.plusMillis(Long.parseLong(matcher.group("ms")));
+ }
+ return result;
+ }
+ return null;
+ }
+
+ public static @NotNull String durationAsString(@NotNull Duration duration) {
+ String result = "";
+ if (duration.toDaysPart() > 0) {
+ result += " " + duration.toDaysPart() + tr("d");
+ }
+ if (duration.toHoursPart() > 0) {
+ result += " " + duration.toHoursPart() + tr("h");
+ }
+ if (duration.toMinutesPart() > 0) {
+ result += " " + duration.toMinutesPart() + tr("m");
+ }
+ if (duration.toSecondsPart() > 0 || result.isEmpty()) {
+ result += " " + duration.toSecondsPart() + tr("s");
+ }
+ return result.trim();
+ }
+
+ public static @NotNull String durationAsShort(@NotNull Duration duration) {
+ long m = duration.toMinutes();
+ long s = duration.toSecondsPart();
+ long ms = duration.toMillisPart();
+ return String.format(tr("{0,number,0}:{1,number,00}.{2,number,000}", m, s, ms));
+ }
+
+ public static long durationAsTicks(@NotNull Duration duration) {
+ return duration.toMillis() / 50;
+ }
+
+ public static @NotNull Duration ticksAsDuration(long ticks) {
+ return Duration.ofMillis(ticks * 50);
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/Timer.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/Timer.java
new file mode 100644
index 000000000..f271fec34
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/Timer.java
@@ -0,0 +1,31 @@
+package dk.lockfuglsang.minecraft.util;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.time.Duration;
+import java.time.Instant;
+
+public class Timer {
+
+ private final Instant start;
+
+ public Timer(@NotNull Instant start) {
+ this.start = start;
+ }
+
+ public Instant getStart() {
+ return start;
+ }
+
+ public Duration elapsed() {
+ return Duration.between(start, Instant.now());
+ }
+
+ public String elapsedAsString() {
+ return TimeUtil.durationAsString(elapsed());
+ }
+
+ public static Timer start() {
+ return new Timer(Instant.now());
+ }
+}
diff --git a/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/VersionUtil.java b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/VersionUtil.java
new file mode 100644
index 000000000..e04f35d93
--- /dev/null
+++ b/bukkit-utils/src/main/java/dk/lockfuglsang/minecraft/util/VersionUtil.java
@@ -0,0 +1,75 @@
+package dk.lockfuglsang.minecraft.util;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility methods for versions
+ * @since v1.15
+ */
+public enum VersionUtil {;
+ private static final Pattern VERSION_PATTERN = Pattern.compile("v?(?[0-9]+)[\\._](?[0-9]+)(?:[\\._](?[0-9]+))?(?.*)");
+ public static Version getVersion(String versionString) {
+ Matcher m = VERSION_PATTERN.matcher(versionString);
+ if (m.matches()) {
+ int major = Integer.parseInt(m.group("major"));
+ int minor = m.group("minor") != null ? Integer.parseInt(m.group("minor")) : 0;
+ int micro = m.group("micro") != null ? Integer.parseInt(m.group("micro")) : 0;
+ return new Version(major, minor, micro, m.group("sub"));
+ }
+ return new Version(0,0,0, null);
+ }
+
+ public static class Version {
+ private int major;
+ private int minor;
+ private int micro;
+ private String sub;
+
+ public Version(int major, int minor, int micro, String sub) {
+ this.major = major;
+ this.minor = minor;
+ this.micro = micro;
+ this.sub = sub;
+ }
+
+ public int getMajor() {
+ return major;
+ }
+
+ public int getMinor() {
+ return minor;
+ }
+
+ public int getMicro() {
+ return micro;
+ }
+
+ public String getSub() {
+ return sub;
+ }
+
+ public boolean isGTE(String version) {
+ Version other = getVersion(version);
+ return major > other.major ||
+ major >= other.major && minor > other.minor ||
+ major >= other.major && minor >= other.minor && micro >= other.micro;
+ }
+
+ public boolean isLT(String version) {
+ Version other = getVersion(version);
+ return major < other.major ||
+ major <= other.major && minor < other.minor ||
+ major <= other.major && minor <= other.minor && micro < other.micro;
+ }
+
+ @Override
+ public String toString() {
+ return "v" + major + "." + minor + "." + micro + (sub != null ? "-" + sub : "");
+ }
+
+ public String toReleaseString() {
+ return "v" + major + "_" + minor;
+ }
+ }
+}
diff --git a/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutorTest.java b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutorTest.java
new file mode 100644
index 000000000..fe211a7d5
--- /dev/null
+++ b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/BaseCommandExecutorTest.java
@@ -0,0 +1,84 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.po.I18nUtil;
+import org.bukkit.command.CommandSender;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+public class BaseCommandExecutorTest {
+ StringBuffer messages = new StringBuffer();
+ private static BaseCommandExecutor mycmd;
+
+ @BeforeClass
+ public static void setUp() {
+ I18nUtil.initialize(new File("."), Locale.ENGLISH);
+ mycmd = new BaseCommandExecutor("mycmd", "myplugin.perm.mycmd", "main myplugin command");
+ mycmd.add(new AbstractCommand("hello|h", "myplugin.perm.hello", "say hello to the player") {
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ sender.sendMessage("Hello! and welcome " + sender.getName(),
+ "I was called with : " + alias,
+ "I had " + args.length + " arguments: " + Arrays.asList(args));
+ return true;
+ }
+ });
+ }
+
+ @Test
+ public void testNoPermissions() {
+ CommandSender sender = createCommandSender();
+ mycmd.onCommand(sender, null, "mycmd", new String[]{"mycmd", "h", "your", "momma"});
+ assertThat(getMessages(), is("§eYou do not have access (§4myplugin.perm.mycmd§e)\n" +
+ "§7Usage: §3mycmd§a [command|help]§7 - §emain myplugin command"));
+ }
+
+ private String getMessages() {
+ return messages.toString().trim();
+ }
+
+ @Test
+ public void testBasic() {
+ CommandSender sender = createCommandSender();
+ addPerm(sender, "myplugin.perm.mycmd");
+ addPerm(sender, "myplugin.perm.hello");
+ mycmd.onCommand(sender, null, "mycmd", new String[]{"h", "your", "momma"});
+ assertThat(getMessages(), is("Hello! and welcome null\nI was called with : h\nI had 2 arguments: [your, momma]"));
+ }
+
+ private void addPerm(CommandSender sender, String s) {
+ when(sender.hasPermission(ArgumentMatchers.isA(String.class))).thenReturn(true);
+ }
+
+ private CommandSender createCommandSender() {
+ CommandSender mock = Mockito.mock(CommandSender.class);
+ Answer answer = invocationOnMock -> {
+ for (Object o : invocationOnMock.getArguments()) {
+ if (o != null && o.getClass().isArray()) {
+ for (Object o2 : (Object[]) o) {
+ messages.append(o2).append("\n");
+ }
+ } else {
+ messages.append(o).append("\n");
+ }
+ }
+ return null;
+ };
+ doAnswer(answer).when(mock).sendMessage(anyString());
+ doAnswer(answer).when(mock).sendMessage(ArgumentMatchers.any(String[].class));
+ return mock;
+ }
+}
diff --git a/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/CompositeCommandTest.java b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/CompositeCommandTest.java
new file mode 100644
index 000000000..d58da4262
--- /dev/null
+++ b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/CompositeCommandTest.java
@@ -0,0 +1,182 @@
+package dk.lockfuglsang.minecraft.command;
+
+import dk.lockfuglsang.minecraft.po.I18nUtil;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.hamcrest.Matchers;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.stubbing.Answer;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+public class CompositeCommandTest {
+ private static final UUID ownerUUID = UUID.randomUUID();
+ private static final UUID adminUUID = UUID.randomUUID();
+ private static final UUID modUUID = UUID.randomUUID();
+ private static BaseCommandExecutor executor;
+
+ @BeforeClass
+ public static void setupAll() {
+ I18nUtil.initialize(new File("."), Locale.ENGLISH);
+ executor = new BaseCommandExecutor("plugin", "plugin", null, "does stuff", ownerUUID);
+ CompositeCommand sut = new CompositeCommand("admin", "admin.admin.superadmin", null, "super important admin command", adminUUID) {
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ sender.sendMessage("executed admin");
+ return super.execute(sender, alias, data, args);
+ }
+ };
+ sut.add(new AbstractCommand("sub", "perm.sub", "some sub-command") {
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ sender.sendMessage("from sub");
+ return false;
+ }
+ });
+ sut.add(new AbstractCommand("sub2", "perm.sub2", "some other sub-command", "yay", null, modUUID) {
+ @Override
+ public boolean execute(CommandSender sender, String alias, Map data, String... args) {
+ sender.sendMessage("from sub2");
+ return false;
+ }
+ });
+ executor.add(sut);
+ }
+
+ @Test
+ public void NoPermOnBase() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.hasPermission(anyString())).thenReturn(false);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ verify(player).sendMessage("§eYou do not have access (§4plugin§e)");
+ }
+
+ @Test
+ public void PermOnBase() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.hasPermission(anyString())).thenAnswer((Answer) invocationOnMock ->
+ invocationOnMock.getArguments()[0] == "plugin");
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ verify(player).sendMessage("§eYou do not have access (§4admin.admin.superadmin§e)");
+ }
+
+ @Test
+ public void PermOnAdmin() {
+ // Arrange
+ Player player = mock(Player.class);
+ final List messages = recordMessages(player);
+ when(player.hasPermission(anyString())).thenAnswer((Answer) invocationOnMock ->
+ invocationOnMock.getArguments()[0] == "plugin"
+ || invocationOnMock.getArguments()[0] == "admin.admin.superadmin"
+ );
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ verify(player).sendMessage("executed admin");
+ assertThat(messages, Matchers.contains("executed admin", "§eYou do not have access (§4perm.sub§e)"));
+ }
+
+ @Test
+ public void AllPerms() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.hasPermission(anyString())).thenReturn(true);
+ final List messages = recordMessages(player);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ verify(player).sendMessage("executed admin");
+ assertThat(messages, Matchers.contains("executed admin", "from sub"));
+ }
+
+ @Test
+ public void NoPerm_PermissionOverride() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(ownerUUID);
+ final List messages = recordMessages(player);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ assertThat(messages, Matchers.contains("executed admin", "from sub"));
+ }
+
+ @Test
+ public void NoPerm_PermissionSubOverride() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(adminUUID);
+ final List messages = recordMessages(player);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ assertThat(messages, Matchers.contains("§eYou do not have access (§4plugin§e)"));
+ }
+
+ @Test
+ public void BasePerm_PermissionCompositeOverride() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(adminUUID);
+ when(player.hasPermission(anyString())).thenAnswer((Answer) invocationOnMock ->
+ invocationOnMock.getArguments()[0] == "plugin"
+ );
+ final List messages = recordMessages(player);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub"});
+
+ assertThat(messages, Matchers.contains("executed admin", "from sub"));
+ }
+
+ @Test
+ public void BasePerm_PermissionAbstractCommandOverride() {
+ // Arrange
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(modUUID);
+ when(player.hasPermission(anyString())).thenAnswer((Answer) invocationOnMock ->
+ invocationOnMock.getArguments()[0] == "plugin"
+ || invocationOnMock.getArguments()[0] == "admin.admin.superadmin"
+ );
+ final List messages = recordMessages(player);
+
+ // Act
+ executor.onCommand(player, null, "alias", new String[]{"admin", "sub2"});
+
+ assertThat(messages, Matchers.contains("executed admin", "from sub2"));
+ }
+
+ private List recordMessages(Player player) {
+ final List messages = new ArrayList<>();
+ Answer voidAnswer = i -> {
+ messages.add(String.join(" ", Arrays.asList(i.getArguments()).toArray(new String[0])));
+ return null;
+ };
+ doAnswer(voidAnswer).when(player).sendMessage(anyString());
+ return messages;
+ }
+
+}
diff --git a/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitorTest.java b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitorTest.java
new file mode 100644
index 000000000..2da17d608
--- /dev/null
+++ b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/command/PluginYamlCommandVisitorTest.java
@@ -0,0 +1,55 @@
+package dk.lockfuglsang.minecraft.command;
+
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+public class PluginYamlCommandVisitorTest {
+ @Test
+ public void writeToSimple() throws Exception {
+ PluginYamlCommandVisitor visitor = new PluginYamlCommandVisitor();
+ BaseCommandExecutor cmd = new BaseCommandExecutor("cmd|c", "plugin.cmd", "player", "some description");
+ cmd.add(new CompositeCommand("sub|s", "plugin.sub", "some sub description"));
+ cmd.add(new CompositeCommand("other", "plugin.cmd.other", "some other command"));
+ BaseCommandExecutor cmd2 = new BaseCommandExecutor("adm|a", "plugin.adm", "hey jude!");
+ cmd2.add(new CompositeCommand("subs|ss", "plugin.sub", "some other sub"));
+ cmd2.add(new CompositeCommand("t2", "plugin.cmdtest", "?optional mandatory", "test"));
+ String expected = String.join(System.lineSeparator(), Files.readAllLines(
+ Paths.get(getClass().getClassLoader().getResource("yml/pluginyml_simple.yml").toURI())));
+
+ cmd.accept(visitor);
+ cmd2.accept(visitor);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ PrintStream out = new PrintStream(baos);
+ visitor.writeTo(out);
+ assertThat(baos.toString(), is(expected));
+ }
+
+ @Test
+ public void writeToSimpleFeatureMap() throws Exception {
+ PluginYamlCommandVisitor visitor = new PluginYamlCommandVisitor();
+ BaseCommandExecutor cmd = new BaseCommandExecutor("cmd|c", "plugin.cmd", "some description");
+ CompositeCommand sub = new CompositeCommand("sub|s", "plugin.sub", "some sub description");
+ cmd.add(sub);
+ sub.addFeaturePermission("plugin.feature.a", "enables A");
+ sub.addFeaturePermission("plugin.feature.b", "enables B");
+ sub.addFeaturePermission("plugin.featuresub", "standalone feature");
+ cmd.add(new CompositeCommand("other", "plugin.cmd.other", "some other command"));
+ String expected = String.join(System.lineSeparator(), Files.readAllLines(
+ Paths.get(getClass().getClassLoader().getResource("yml/pluginyml_featuremap.yml").toURI())));
+
+ cmd.accept(visitor);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ PrintStream out = new PrintStream(baos);
+ visitor.writeTo(out);
+ assertThat(baos.toString(), is(expected));
+ }
+}
diff --git a/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/file/FileUtilTest.java b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/file/FileUtilTest.java
new file mode 100644
index 000000000..a65c357f4
--- /dev/null
+++ b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/file/FileUtilTest.java
@@ -0,0 +1,23 @@
+package dk.lockfuglsang.minecraft.file;
+
+import org.junit.Test;
+
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * JUnit tests for FileUtil
+ */
+public class FileUtilTest {
+ @Test
+ public void testGetExtension() {
+ assertThat(FileUtil.getExtension("basename.ext"), is("ext"));
+ assertThat(FileUtil.getExtension("my file.with.dot.yml"), is("yml"));
+ }
+
+ @Test
+ public void testBaseName() {
+ assertThat(FileUtil.getBasename("dir/something/filename.txt"), is("filename"));
+ assertThat(FileUtil.getBasename("dir\\something\\filename.txt"), is("filename"));
+ }
+}
diff --git a/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/nbt/NBTUtilTest.java b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/nbt/NBTUtilTest.java
new file mode 100644
index 000000000..911df5c52
--- /dev/null
+++ b/bukkit-utils/src/test/java/dk/lockfuglsang/minecraft/nbt/NBTUtilTest.java
@@ -0,0 +1,39 @@
+package dk.lockfuglsang.minecraft.nbt;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+
+import java.io.StringReader;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Tests the NBTUtil
+ */
+public class NBTUtilTest {
+
+ /**
+ * Tests that the JSONMap will return the proper syntax.
+ */
+ @Test
+ public void testJSONMap() {
+ String jsonString = "{\"Potion\":\"minecraft:empty\",\"CustomPotionEffects\":[{\"Id\":1},{\"Id\":2}]}";
+ Gson gson = new Gson();
+
+ Map map = gson.fromJson(new StringReader(jsonString), new TypeToken