diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..af3101c
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,37 @@
+plugins {
+ id 'java'
+ id 'net.ltgt.apt' version '0.10'
+}
+
+group 'sh.okx'
+version '3.0-alpha'
+
+sourceCompatibility = 1.8
+
+repositories {
+ mavenCentral()
+ jcenter()
+ maven {
+ url 'http://nexus.hc.to/content/repositories/pub_releases'
+ }
+ maven {
+ url 'https://hub.spigotmc.org/nexus/content/groups/public/'
+ }
+ maven {
+ url 'http://repo.extendedclip.com/content/repositories/placeholderapi/'
+ }
+}
+
+dependencies {
+ testCompile group: 'junit', name: 'junit', version: '4.12'
+ testCompile 'org.mockito:mockito-core:2.+'
+
+ compileOnly 'org.projectlombok:lombok:1.18.2'
+ apt "org.projectlombok:lombok:1.18.2"
+
+ compile 'org.spigotmc:spigot-api:1.13-R0.1-SNAPSHOT'
+ compile('net.milkbowl.vault:VaultAPI:1.6') {
+ exclude group: 'org.bukkit'
+ }
+ compile 'me.clip:placeholderapi:2.9.+'
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..01b8bf6
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..7eb5e88
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Aug 21 18:52:26 BST 2018
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..cccdd3d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..f955316
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..423c743
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'Rankup'
+
diff --git a/src/main/java/sh/okx/rankup/Metrics.java b/src/main/java/sh/okx/rankup/Metrics.java
new file mode 100644
index 0000000..fba3376
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/Metrics.java
@@ -0,0 +1,926 @@
+package sh.okx.rankup;
+
+import org.bukkit.Bukkit;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.ServicePriority;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+
+import javax.net.ssl.HttpsURLConnection;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.UUID;
+import java.util.logging.Level;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * bStats collects some data for plugin authors.
+ *
+ * Check out https://bStats.org/ to learn more about bStats!
+ */
+public class Metrics {
+
+ // The version of this bStats class
+ public static final int B_STATS_VERSION = 1;
+
+ // The url to which the data is sent
+ private static final String URL = "https://bStats.org/submitData";
+
+ // Should failed requests be logged?
+ private static boolean logFailedRequests;
+
+ // The uuid of the server
+ private static String serverUUID;
+
+ // The plugin
+ private final JavaPlugin plugin;
+
+ // A list with all custom charts
+ private final List charts = new ArrayList<>();
+
+ /**
+ * Class constructor.
+ *
+ * @param plugin The plugin which stats should be submitted.
+ */
+ public Metrics(JavaPlugin plugin) {
+ if (plugin == null) {
+ throw new IllegalArgumentException("Plugin cannot be null!");
+ }
+ this.plugin = plugin;
+
+ // Get the config file
+ File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats");
+ File configFile = new File(bStatsFolder, "config.yml");
+ YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile);
+
+ // Check if the config file exists
+ if (!config.isSet("serverUuid")) {
+
+ // Add default values
+ config.addDefault("enabled", true);
+ // Every server gets it's unique random id.
+ config.addDefault("serverUuid", UUID.randomUUID().toString());
+ // Should failed request be logged?
+ config.addDefault("logFailedRequests", false);
+
+ // Inform the server owners about bStats
+ config.options().header(
+ "bStats collects some data for plugin authors like how many servers are using their plugins.\n" +
+ "To honor their work, you should not disable it.\n" +
+ "This has nearly no effect on the server performance!\n" +
+ "Check out https://bStats.org/ to learn more :)"
+ ).copyDefaults(true);
+ try {
+ config.save(configFile);
+ } catch (IOException ignored) {
+ }
+ }
+
+ // Load the data
+ serverUUID = config.getString("serverUuid");
+ logFailedRequests = config.getBoolean("logFailedRequests", false);
+ if (config.getBoolean("enabled", true)) {
+ boolean found = false;
+ // Search for all other bStats Metrics classes to see if we are the first one
+ for (Class> service : Bukkit.getServicesManager().getKnownServices()) {
+ try {
+ service.getField("B_STATS_VERSION"); // Our identifier :)
+ found = true; // We aren't the first
+ break;
+ } catch (NoSuchFieldException ignored) {
+ }
+ }
+ // Register our service
+ Bukkit.getServicesManager().register(Metrics.class, this, plugin, ServicePriority.Normal);
+ if (!found) {
+ // We are the first!
+ startSubmitting();
+ }
+ }
+ }
+
+ /**
+ * Adds a custom chart.
+ *
+ * @param chart The chart to add.
+ */
+ public void addCustomChart(CustomChart chart) {
+ if (chart == null) {
+ throw new IllegalArgumentException("Chart cannot be null!");
+ }
+ charts.add(chart);
+ }
+
+ /**
+ * Starts the Scheduler which submits our data every 30 minutes.
+ */
+ private void startSubmitting() {
+ final Timer timer = new Timer(true); // We use a timer cause the Bukkit scheduler is affected by server lags
+ timer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ if (!plugin.isEnabled()) { // Plugin was disabled
+ timer.cancel();
+ return;
+ }
+ // Nevertheless we want our code to run in the Bukkit main thread, so we have to use the Bukkit scheduler
+ // Don't be afraid! The connection to the bStats server is still async, only the stats collection is sync ;)
+ Bukkit.getScheduler().runTask(plugin, new Runnable() {
+ @Override
+ public void run() {
+ submitData();
+ }
+ });
+ }
+ }, 1000 * 60 * 5, 1000 * 60 * 30);
+ // Submit the data every 30 minutes, first time after 5 minutes to give other plugins enough time to start
+ // WARNING: Changing the frequency has no effect but your plugin WILL be blocked/deleted!
+ // WARNING: Just don't do it!
+ }
+
+ /**
+ * Gets the plugin specific data.
+ * This method is called using Reflection.
+ *
+ * @return The plugin specific data.
+ */
+ public JSONObject getPluginData() {
+ JSONObject data = new JSONObject();
+
+ String pluginName = plugin.getDescription().getName();
+ String pluginVersion = plugin.getDescription().getVersion();
+
+ data.put("pluginName", pluginName); // Append the name of the plugin
+ data.put("pluginVersion", pluginVersion); // Append the version of the plugin
+ JSONArray customCharts = new JSONArray();
+ for (CustomChart customChart : charts) {
+ // Add the data of the custom charts
+ JSONObject chart = customChart.getRequestJsonObject();
+ if (chart == null) { // If the chart is null, we skip it
+ continue;
+ }
+ customCharts.add(chart);
+ }
+ data.put("customCharts", customCharts);
+
+ return data;
+ }
+
+ /**
+ * Gets the server specific data.
+ *
+ * @return The server specific data.
+ */
+ private JSONObject getServerData() {
+ // Minecraft specific data
+ int playerAmount = Bukkit.getOnlinePlayers().size();
+ int onlineMode = Bukkit.getOnlineMode() ? 1 : 0;
+ String bukkitVersion = org.bukkit.Bukkit.getVersion();
+ bukkitVersion = bukkitVersion.substring(bukkitVersion.indexOf("MC: ") + 4, bukkitVersion.length() - 1);
+
+ // OS/Java specific data
+ String javaVersion = System.getProperty("java.version");
+ String osName = System.getProperty("os.name");
+ String osArch = System.getProperty("os.arch");
+ String osVersion = System.getProperty("os.version");
+ int coreCount = Runtime.getRuntime().availableProcessors();
+
+ JSONObject data = new JSONObject();
+
+ data.put("serverUUID", serverUUID);
+
+ data.put("playerAmount", playerAmount);
+ data.put("onlineMode", onlineMode);
+ data.put("bukkitVersion", bukkitVersion);
+
+ data.put("javaVersion", javaVersion);
+ data.put("osName", osName);
+ data.put("osArch", osArch);
+ data.put("osVersion", osVersion);
+ data.put("coreCount", coreCount);
+
+ return data;
+ }
+
+ /**
+ * Collects the data and sends it afterwards.
+ */
+ private void submitData() {
+ final JSONObject data = getServerData();
+
+ JSONArray pluginData = new JSONArray();
+ // Search for all other bStats Metrics classes to get their plugin data
+ for (Class> service : Bukkit.getServicesManager().getKnownServices()) {
+ try {
+ service.getField("B_STATS_VERSION"); // Our identifier :)
+ } catch (NoSuchFieldException ignored) {
+ continue; // Continue "searching"
+ }
+ // Found one!
+ try {
+ pluginData.add(service.getMethod("getPluginData").invoke(Bukkit.getServicesManager().load(service)));
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ignored) {
+ }
+ }
+
+ data.put("plugins", pluginData);
+
+ // Create a new thread for the connection to the bStats server
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ // Send the data
+ sendData(data);
+ } catch (Exception e) {
+ // Something went wrong! :(
+ if (logFailedRequests) {
+ plugin.getLogger().log(Level.WARNING, "Could not submit plugin stats of " + plugin.getName(), e);
+ }
+ }
+ }
+ }).start();
+
+ }
+
+ /**
+ * Sends the data to the bStats server.
+ *
+ * @param data The data to send.
+ * @throws Exception If the request failed.
+ */
+ private static void sendData(JSONObject data) throws Exception {
+ if (data == null) {
+ throw new IllegalArgumentException("Data cannot be null!");
+ }
+ if (Bukkit.isPrimaryThread()) {
+ throw new IllegalAccessException("This method must not be called from the main thread!");
+ }
+ HttpsURLConnection connection = (HttpsURLConnection) new URL(URL).openConnection();
+
+ // Compress the data to save bandwidth
+ byte[] compressedData = compress(data.toString());
+
+ // Add headers
+ connection.setRequestMethod("POST");
+ connection.addRequestProperty("Accept", "application/json");
+ connection.addRequestProperty("Connection", "close");
+ connection.addRequestProperty("Content-Encoding", "gzip"); // We gzip our request
+ connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length));
+ connection.setRequestProperty("Content-Type", "application/json"); // We send our data in JSON format
+ connection.setRequestProperty("User-Agent", "MC-Server/" + B_STATS_VERSION);
+
+ // Send data
+ connection.setDoOutput(true);
+ DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream());
+ outputStream.write(compressedData);
+ outputStream.flush();
+ outputStream.close();
+
+ connection.getInputStream().close(); // We don't care about the response - Just send our data :)
+ }
+
+ /**
+ * Gzips the given String.
+ *
+ * @param str The string to gzip.
+ * @return The gzipped String.
+ * @throws IOException If the compression failed.
+ */
+ private static byte[] compress(final String str) throws IOException {
+ if (str == null) {
+ return null;
+ }
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ GZIPOutputStream gzip = new GZIPOutputStream(outputStream);
+ gzip.write(str.getBytes("UTF-8"));
+ gzip.close();
+ return outputStream.toByteArray();
+ }
+
+ /**
+ * Represents a custom chart.
+ */
+ public static abstract class CustomChart {
+
+ // The id of the chart
+ protected final String chartId;
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public CustomChart(String chartId) {
+ if (chartId == null || chartId.isEmpty()) {
+ throw new IllegalArgumentException("ChartId cannot be null or empty!");
+ }
+ this.chartId = chartId;
+ }
+
+ protected JSONObject getRequestJsonObject() {
+ JSONObject chart = new JSONObject();
+ chart.put("chartId", chartId);
+ try {
+ JSONObject data = getChartData();
+ if (data == null) {
+ // If the data is null we don't send the chart.
+ return null;
+ }
+ chart.put("data", data);
+ } catch (Throwable t) {
+ if (logFailedRequests) {
+ Bukkit.getLogger().log(Level.WARNING, "Failed to get data for custom chart with id " + chartId, t);
+ }
+ return null;
+ }
+ return chart;
+ }
+
+ protected abstract JSONObject getChartData();
+
+ }
+
+ /**
+ * Represents a custom simple pie.
+ */
+ public static abstract class SimplePie extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public SimplePie(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the value of the pie.
+ *
+ * @return The value of the pie.
+ */
+ public abstract String getValue();
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ String value = getValue();
+ if (value == null || value.isEmpty()) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("value", value);
+ return data;
+ }
+ }
+
+ /**
+ * Represents a custom advanced pie.
+ */
+ public static abstract class AdvancedPie extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public AdvancedPie(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the values of the pie.
+ *
+ * @param valueMap Just an empty map. The only reason it exists is to make your life easier.
+ * You don't have to create a map yourself!
+ * @return The values of the pie.
+ */
+ public abstract HashMap getValues(HashMap valueMap);
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ JSONObject values = new JSONObject();
+ HashMap map = getValues(new HashMap());
+ if (map == null || map.isEmpty()) {
+ // Null = skip the chart
+ return null;
+ }
+ boolean allSkipped = true;
+ for (Map.Entry entry : map.entrySet()) {
+ if (entry.getValue() == 0) {
+ continue; // Skip this invalid
+ }
+ allSkipped = false;
+ values.put(entry.getKey(), entry.getValue());
+ }
+ if (allSkipped) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("values", values);
+ return data;
+ }
+ }
+
+ /**
+ * Represents a custom single line chart.
+ */
+ public static abstract class SingleLineChart extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public SingleLineChart(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the value of the chart.
+ *
+ * @return The value of the chart.
+ */
+ public abstract int getValue();
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ int value = getValue();
+ if (value == 0) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("value", value);
+ return data;
+ }
+
+ }
+
+ /**
+ * Represents a custom multi line chart.
+ */
+ public static abstract class MultiLineChart extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public MultiLineChart(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the values of the chart.
+ *
+ * @param valueMap Just an empty map. The only reason it exists is to make your life easier.
+ * You don't have to create a map yourself!
+ * @return The values of the chart.
+ */
+ public abstract HashMap getValues(HashMap valueMap);
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ JSONObject values = new JSONObject();
+ HashMap map = getValues(new HashMap());
+ if (map == null || map.isEmpty()) {
+ // Null = skip the chart
+ return null;
+ }
+ boolean allSkipped = true;
+ for (Map.Entry entry : map.entrySet()) {
+ if (entry.getValue() == 0) {
+ continue; // Skip this invalid
+ }
+ allSkipped = false;
+ values.put(entry.getKey(), entry.getValue());
+ }
+ if (allSkipped) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("values", values);
+ return data;
+ }
+
+ }
+
+ /**
+ * Represents a custom simple map chart.
+ */
+ public static abstract class SimpleMapChart extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public SimpleMapChart(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the value of the chart.
+ *
+ * @return The value of the chart.
+ */
+ public abstract Country getValue();
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ Country value = getValue();
+
+ if (value == null) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("value", value.getCountryIsoTag());
+ return data;
+ }
+
+ }
+
+ /**
+ * Represents a custom advanced map chart.
+ */
+ public static abstract class AdvancedMapChart extends CustomChart {
+
+ /**
+ * Class constructor.
+ *
+ * @param chartId The id of the chart.
+ */
+ public AdvancedMapChart(String chartId) {
+ super(chartId);
+ }
+
+ /**
+ * Gets the value of the chart.
+ *
+ * @param valueMap Just an empty map. The only reason it exists is to make your life easier.
+ * You don't have to create a map yourself!
+ * @return The value of the chart.
+ */
+ public abstract HashMap getValues(HashMap valueMap);
+
+ @Override
+ protected JSONObject getChartData() {
+ JSONObject data = new JSONObject();
+ JSONObject values = new JSONObject();
+ HashMap map = getValues(new HashMap());
+ if (map == null || map.isEmpty()) {
+ // Null = skip the chart
+ return null;
+ }
+ boolean allSkipped = true;
+ for (Map.Entry entry : map.entrySet()) {
+ if (entry.getValue() == 0) {
+ continue; // Skip this invalid
+ }
+ allSkipped = false;
+ values.put(entry.getKey().getCountryIsoTag(), entry.getValue());
+ }
+ if (allSkipped) {
+ // Null = skip the chart
+ return null;
+ }
+ data.put("values", values);
+ return data;
+ }
+
+ }
+
+ /**
+ * A enum which is used for custom maps.
+ */
+ public enum Country {
+
+ /**
+ * bStats will use the country of the server.
+ */
+ AUTO_DETECT("AUTO", "Auto Detected"),
+
+ ANDORRA("AD", "Andorra"),
+ UNITED_ARAB_EMIRATES("AE", "United Arab Emirates"),
+ AFGHANISTAN("AF", "Afghanistan"),
+ ANTIGUA_AND_BARBUDA("AG", "Antigua and Barbuda"),
+ ANGUILLA("AI", "Anguilla"),
+ ALBANIA("AL", "Albania"),
+ ARMENIA("AM", "Armenia"),
+ NETHERLANDS_ANTILLES("AN", "Netherlands Antilles"),
+ ANGOLA("AO", "Angola"),
+ ANTARCTICA("AQ", "Antarctica"),
+ ARGENTINA("AR", "Argentina"),
+ AMERICAN_SAMOA("AS", "American Samoa"),
+ AUSTRIA("AT", "Austria"),
+ AUSTRALIA("AU", "Australia"),
+ ARUBA("AW", "Aruba"),
+ ÅLAND_ISLANDS("AX", "Åland Islands"),
+ AZERBAIJAN("AZ", "Azerbaijan"),
+ BOSNIA_AND_HERZEGOVINA("BA", "Bosnia and Herzegovina"),
+ BARBADOS("BB", "Barbados"),
+ BANGLADESH("BD", "Bangladesh"),
+ BELGIUM("BE", "Belgium"),
+ BURKINA_FASO("BF", "Burkina Faso"),
+ BULGARIA("BG", "Bulgaria"),
+ BAHRAIN("BH", "Bahrain"),
+ BURUNDI("BI", "Burundi"),
+ BENIN("BJ", "Benin"),
+ SAINT_BARTHÉLEMY("BL", "Saint Barthélemy"),
+ BERMUDA("BM", "Bermuda"),
+ BRUNEI("BN", "Brunei"),
+ BOLIVIA("BO", "Bolivia"),
+ BONAIRE_SINT_EUSTATIUS_AND_SABA("BQ", "Bonaire, Sint Eustatius and Saba"),
+ BRAZIL("BR", "Brazil"),
+ BAHAMAS("BS", "Bahamas"),
+ BHUTAN("BT", "Bhutan"),
+ BOUVET_ISLAND("BV", "Bouvet Island"),
+ BOTSWANA("BW", "Botswana"),
+ BELARUS("BY", "Belarus"),
+ BELIZE("BZ", "Belize"),
+ CANADA("CA", "Canada"),
+ COCOS_ISLANDS("CC", "Cocos Islands"),
+ THE_DEMOCRATIC_REPUBLIC_OF_CONGO("CD", "The Democratic Republic Of Congo"),
+ CENTRAL_AFRICAN_REPUBLIC("CF", "Central African Republic"),
+ CONGO("CG", "Congo"),
+ SWITZERLAND("CH", "Switzerland"),
+ CÔTE_D_IVOIRE("CI", "Côte d'Ivoire"),
+ COOK_ISLANDS("CK", "Cook Islands"),
+ CHILE("CL", "Chile"),
+ CAMEROON("CM", "Cameroon"),
+ CHINA("CN", "China"),
+ COLOMBIA("CO", "Colombia"),
+ COSTA_RICA("CR", "Costa Rica"),
+ CUBA("CU", "Cuba"),
+ CAPE_VERDE("CV", "Cape Verde"),
+ CURAÇAO("CW", "Curaçao"),
+ CHRISTMAS_ISLAND("CX", "Christmas Island"),
+ CYPRUS("CY", "Cyprus"),
+ CZECH_REPUBLIC("CZ", "Czech Republic"),
+ GERMANY("DE", "Germany"),
+ DJIBOUTI("DJ", "Djibouti"),
+ DENMARK("DK", "Denmark"),
+ DOMINICA("DM", "Dominica"),
+ DOMINICAN_REPUBLIC("DO", "Dominican Republic"),
+ ALGERIA("DZ", "Algeria"),
+ ECUADOR("EC", "Ecuador"),
+ ESTONIA("EE", "Estonia"),
+ EGYPT("EG", "Egypt"),
+ WESTERN_SAHARA("EH", "Western Sahara"),
+ ERITREA("ER", "Eritrea"),
+ SPAIN("ES", "Spain"),
+ ETHIOPIA("ET", "Ethiopia"),
+ FINLAND("FI", "Finland"),
+ FIJI("FJ", "Fiji"),
+ FALKLAND_ISLANDS("FK", "Falkland Islands"),
+ MICRONESIA("FM", "Micronesia"),
+ FAROE_ISLANDS("FO", "Faroe Islands"),
+ FRANCE("FR", "France"),
+ GABON("GA", "Gabon"),
+ UNITED_KINGDOM("GB", "United Kingdom"),
+ GRENADA("GD", "Grenada"),
+ GEORGIA("GE", "Georgia"),
+ FRENCH_GUIANA("GF", "French Guiana"),
+ GUERNSEY("GG", "Guernsey"),
+ GHANA("GH", "Ghana"),
+ GIBRALTAR("GI", "Gibraltar"),
+ GREENLAND("GL", "Greenland"),
+ GAMBIA("GM", "Gambia"),
+ GUINEA("GN", "Guinea"),
+ GUADELOUPE("GP", "Guadeloupe"),
+ EQUATORIAL_GUINEA("GQ", "Equatorial Guinea"),
+ GREECE("GR", "Greece"),
+ SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS("GS", "South Georgia And The South Sandwich Islands"),
+ GUATEMALA("GT", "Guatemala"),
+ GUAM("GU", "Guam"),
+ GUINEA_BISSAU("GW", "Guinea-Bissau"),
+ GUYANA("GY", "Guyana"),
+ HONG_KONG("HK", "Hong Kong"),
+ HEARD_ISLAND_AND_MCDONALD_ISLANDS("HM", "Heard Island And McDonald Islands"),
+ HONDURAS("HN", "Honduras"),
+ CROATIA("HR", "Croatia"),
+ HAITI("HT", "Haiti"),
+ HUNGARY("HU", "Hungary"),
+ INDONESIA("ID", "Indonesia"),
+ IRELAND("IE", "Ireland"),
+ ISRAEL("IL", "Israel"),
+ ISLE_OF_MAN("IM", "Isle Of Man"),
+ INDIA("IN", "India"),
+ BRITISH_INDIAN_OCEAN_TERRITORY("IO", "British Indian Ocean Territory"),
+ IRAQ("IQ", "Iraq"),
+ IRAN("IR", "Iran"),
+ ICELAND("IS", "Iceland"),
+ ITALY("IT", "Italy"),
+ JERSEY("JE", "Jersey"),
+ JAMAICA("JM", "Jamaica"),
+ JORDAN("JO", "Jordan"),
+ JAPAN("JP", "Japan"),
+ KENYA("KE", "Kenya"),
+ KYRGYZSTAN("KG", "Kyrgyzstan"),
+ CAMBODIA("KH", "Cambodia"),
+ KIRIBATI("KI", "Kiribati"),
+ COMOROS("KM", "Comoros"),
+ SAINT_KITTS_AND_NEVIS("KN", "Saint Kitts And Nevis"),
+ NORTH_KOREA("KP", "North Korea"),
+ SOUTH_KOREA("KR", "South Korea"),
+ KUWAIT("KW", "Kuwait"),
+ CAYMAN_ISLANDS("KY", "Cayman Islands"),
+ KAZAKHSTAN("KZ", "Kazakhstan"),
+ LAOS("LA", "Laos"),
+ LEBANON("LB", "Lebanon"),
+ SAINT_LUCIA("LC", "Saint Lucia"),
+ LIECHTENSTEIN("LI", "Liechtenstein"),
+ SRI_LANKA("LK", "Sri Lanka"),
+ LIBERIA("LR", "Liberia"),
+ LESOTHO("LS", "Lesotho"),
+ LITHUANIA("LT", "Lithuania"),
+ LUXEMBOURG("LU", "Luxembourg"),
+ LATVIA("LV", "Latvia"),
+ LIBYA("LY", "Libya"),
+ MOROCCO("MA", "Morocco"),
+ MONACO("MC", "Monaco"),
+ MOLDOVA("MD", "Moldova"),
+ MONTENEGRO("ME", "Montenegro"),
+ SAINT_MARTIN("MF", "Saint Martin"),
+ MADAGASCAR("MG", "Madagascar"),
+ MARSHALL_ISLANDS("MH", "Marshall Islands"),
+ MACEDONIA("MK", "Macedonia"),
+ MALI("ML", "Mali"),
+ MYANMAR("MM", "Myanmar"),
+ MONGOLIA("MN", "Mongolia"),
+ MACAO("MO", "Macao"),
+ NORTHERN_MARIANA_ISLANDS("MP", "Northern Mariana Islands"),
+ MARTINIQUE("MQ", "Martinique"),
+ MAURITANIA("MR", "Mauritania"),
+ MONTSERRAT("MS", "Montserrat"),
+ MALTA("MT", "Malta"),
+ MAURITIUS("MU", "Mauritius"),
+ MALDIVES("MV", "Maldives"),
+ MALAWI("MW", "Malawi"),
+ MEXICO("MX", "Mexico"),
+ MALAYSIA("MY", "Malaysia"),
+ MOZAMBIQUE("MZ", "Mozambique"),
+ NAMIBIA("NA", "Namibia"),
+ NEW_CALEDONIA("NC", "New Caledonia"),
+ NIGER("NE", "Niger"),
+ NORFOLK_ISLAND("NF", "Norfolk Island"),
+ NIGERIA("NG", "Nigeria"),
+ NICARAGUA("NI", "Nicaragua"),
+ NETHERLANDS("NL", "Netherlands"),
+ NORWAY("NO", "Norway"),
+ NEPAL("NP", "Nepal"),
+ NAURU("NR", "Nauru"),
+ NIUE("NU", "Niue"),
+ NEW_ZEALAND("NZ", "New Zealand"),
+ OMAN("OM", "Oman"),
+ PANAMA("PA", "Panama"),
+ PERU("PE", "Peru"),
+ FRENCH_POLYNESIA("PF", "French Polynesia"),
+ PAPUA_NEW_GUINEA("PG", "Papua New Guinea"),
+ PHILIPPINES("PH", "Philippines"),
+ PAKISTAN("PK", "Pakistan"),
+ POLAND("PL", "Poland"),
+ SAINT_PIERRE_AND_MIQUELON("PM", "Saint Pierre And Miquelon"),
+ PITCAIRN("PN", "Pitcairn"),
+ PUERTO_RICO("PR", "Puerto Rico"),
+ PALESTINE("PS", "Palestine"),
+ PORTUGAL("PT", "Portugal"),
+ PALAU("PW", "Palau"),
+ PARAGUAY("PY", "Paraguay"),
+ QATAR("QA", "Qatar"),
+ REUNION("RE", "Reunion"),
+ ROMANIA("RO", "Romania"),
+ SERBIA("RS", "Serbia"),
+ RUSSIA("RU", "Russia"),
+ RWANDA("RW", "Rwanda"),
+ SAUDI_ARABIA("SA", "Saudi Arabia"),
+ SOLOMON_ISLANDS("SB", "Solomon Islands"),
+ SEYCHELLES("SC", "Seychelles"),
+ SUDAN("SD", "Sudan"),
+ SWEDEN("SE", "Sweden"),
+ SINGAPORE("SG", "Singapore"),
+ SAINT_HELENA("SH", "Saint Helena"),
+ SLOVENIA("SI", "Slovenia"),
+ SVALBARD_AND_JAN_MAYEN("SJ", "Svalbard And Jan Mayen"),
+ SLOVAKIA("SK", "Slovakia"),
+ SIERRA_LEONE("SL", "Sierra Leone"),
+ SAN_MARINO("SM", "San Marino"),
+ SENEGAL("SN", "Senegal"),
+ SOMALIA("SO", "Somalia"),
+ SURINAME("SR", "Suriname"),
+ SOUTH_SUDAN("SS", "South Sudan"),
+ SAO_TOME_AND_PRINCIPE("ST", "Sao Tome And Principe"),
+ EL_SALVADOR("SV", "El Salvador"),
+ SINT_MAARTEN_DUTCH_PART("SX", "Sint Maarten (Dutch part)"),
+ SYRIA("SY", "Syria"),
+ SWAZILAND("SZ", "Swaziland"),
+ TURKS_AND_CAICOS_ISLANDS("TC", "Turks And Caicos Islands"),
+ CHAD("TD", "Chad"),
+ FRENCH_SOUTHERN_TERRITORIES("TF", "French Southern Territories"),
+ TOGO("TG", "Togo"),
+ THAILAND("TH", "Thailand"),
+ TAJIKISTAN("TJ", "Tajikistan"),
+ TOKELAU("TK", "Tokelau"),
+ TIMOR_LESTE("TL", "Timor-Leste"),
+ TURKMENISTAN("TM", "Turkmenistan"),
+ TUNISIA("TN", "Tunisia"),
+ TONGA("TO", "Tonga"),
+ TURKEY("TR", "Turkey"),
+ TRINIDAD_AND_TOBAGO("TT", "Trinidad and Tobago"),
+ TUVALU("TV", "Tuvalu"),
+ TAIWAN("TW", "Taiwan"),
+ TANZANIA("TZ", "Tanzania"),
+ UKRAINE("UA", "Ukraine"),
+ UGANDA("UG", "Uganda"),
+ UNITED_STATES_MINOR_OUTLYING_ISLANDS("UM", "United States Minor Outlying Islands"),
+ UNITED_STATES("US", "United States"),
+ URUGUAY("UY", "Uruguay"),
+ UZBEKISTAN("UZ", "Uzbekistan"),
+ VATICAN("VA", "Vatican"),
+ SAINT_VINCENT_AND_THE_GRENADINES("VC", "Saint Vincent And The Grenadines"),
+ VENEZUELA("VE", "Venezuela"),
+ BRITISH_VIRGIN_ISLANDS("VG", "British Virgin Islands"),
+ U_S__VIRGIN_ISLANDS("VI", "U.S. Virgin Islands"),
+ VIETNAM("VN", "Vietnam"),
+ VANUATU("VU", "Vanuatu"),
+ WALLIS_AND_FUTUNA("WF", "Wallis And Futuna"),
+ SAMOA("WS", "Samoa"),
+ YEMEN("YE", "Yemen"),
+ MAYOTTE("YT", "Mayotte"),
+ SOUTH_AFRICA("ZA", "South Africa"),
+ ZAMBIA("ZM", "Zambia"),
+ ZIMBABWE("ZW", "Zimbabwe");
+
+ private String isoTag;
+ private String name;
+
+ Country(String isoTag, String name) {
+ this.isoTag = isoTag;
+ this.name = name;
+ }
+
+ /**
+ * Gets the name of the country.
+ *
+ * @return The name of the country.
+ */
+ public String getCountryName() {
+ return name;
+ }
+
+ /**
+ * Gets the iso tag of the country.
+ *
+ * @return The iso tag of the country.
+ */
+ public String getCountryIsoTag() {
+ return isoTag;
+ }
+
+ /**
+ * Gets a country by it's iso tag.
+ *
+ * @param isoTag The iso tag of the county.
+ * @return The country with the given iso tag or null if unknown.
+ */
+ public static Country byIsoTag(String isoTag) {
+ for (Country country : Country.values()) {
+ if (country.getCountryIsoTag().equals(isoTag)) {
+ return country;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Gets a country by a locale.
+ *
+ * @param locale The locale.
+ * @return The country from the giben locale or null if unknown country or
+ * if the locale does not contain a country.
+ */
+ public static Country byLocale(Locale locale) {
+ return byIsoTag(locale.getCountry());
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/sh/okx/rankup/Rankup.java b/src/main/java/sh/okx/rankup/Rankup.java
new file mode 100644
index 0000000..555c56c
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/Rankup.java
@@ -0,0 +1,220 @@
+package sh.okx.rankup;
+
+import lombok.Getter;
+import me.clip.placeholderapi.PlaceholderAPI;
+import net.milkbowl.vault.economy.Economy;
+import net.milkbowl.vault.permission.Permission;
+import org.bukkit.Bukkit;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryType;
+import org.bukkit.inventory.InventoryView;
+import org.bukkit.plugin.RegisteredServiceProvider;
+import org.bukkit.plugin.java.JavaPlugin;
+import sh.okx.rankup.commands.InfoCommand;
+import sh.okx.rankup.commands.RankListCommand;
+import sh.okx.rankup.commands.RankupCommand;
+import sh.okx.rankup.gui.Gui;
+import sh.okx.rankup.gui.GuiListener;
+import sh.okx.rankup.messages.Message;
+import sh.okx.rankup.messages.MessageBuilder;
+import sh.okx.rankup.messages.Variable;
+import sh.okx.rankup.placeholders.Placeholders;
+import sh.okx.rankup.ranks.Rank;
+import sh.okx.rankup.ranks.Rankups;
+import sh.okx.rankup.ranks.requirements.PlaytimeHoursRequirement;
+import sh.okx.rankup.ranks.requirements.XpLevelRequirement;
+import sh.okx.rankup.ranks.requirements.MoneyRequirement;
+import sh.okx.rankup.ranks.requirements.RequirementRegistry;
+
+import java.io.File;
+
+public class Rankup extends JavaPlugin {
+ @Getter
+ private Permission permissions;
+ @Getter
+ private Economy economy;
+ /**
+ * The registry for listing the requirements to /rankup.
+ */
+ @Getter
+ private RequirementRegistry requirementRegistry = new RequirementRegistry();
+ @Getter
+ private FileConfiguration messages;
+ @Getter
+ private FileConfiguration config;
+ @Getter
+ private Rankups rankups;
+ @Getter
+ private Placeholders placeholders;
+
+ @Override
+ public void onEnable() {
+ registerRequirements();
+ setupPermissions();
+ setupEconomy();
+ reload();
+
+ Metrics metrics = new Metrics(this);
+ metrics.addCustomChart(new Metrics.SimplePie("confirmation") {
+ @Override
+ public String getValue() {
+ return getConfig().getString("confirmation.type");
+ }
+ });
+
+ getCommand("rankup").setExecutor(new RankupCommand(this));
+ getCommand("rankup3").setExecutor(new InfoCommand(this));
+ getCommand("ranks").setExecutor(new RankListCommand(this));
+ getServer().getPluginManager().registerEvents(new GuiListener(this), this);
+ }
+
+
+ @Override
+ public void onDisable() {
+ closeInventories();
+ PlaceholderAPI.unregisterExpansion(placeholders);
+ }
+
+ public void reload() {
+ closeInventories();
+ loadConfigs();
+ if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
+ placeholders = new Placeholders(this);
+ placeholders.register();
+ }
+
+ if(config.getInt("version") != YamlConfiguration.loadConfiguration(getTextResource("config.yml")).getInt("version")) {
+ getLogger().severe("You are using an outdated config!");
+ getLogger().severe("This means that some things might not work!");
+ getLogger().severe("To update, please rename your config files (or the folder they are in),");
+ getLogger().severe("and run /rankup3 reload to generate a new config file.");
+ getLogger().severe("If that does not work, restart your server.");
+ getLogger().severe("You may then copy in your config values from the old config.");
+ getLogger().severe("Check the changelog on the Rankup spigot page to see the changes.");
+ }
+ }
+
+ /**
+ * Closes all rankup inventories on disable
+ * so players cannot grab items from the inventory
+ * on a plugin reload.
+ */
+ private void closeInventories() {
+ for(Player player : Bukkit.getOnlinePlayers()) {
+ InventoryView view = player.getOpenInventory();
+ if(view.getType() == InventoryType.CHEST
+ && view.getTopInventory().getHolder() instanceof Gui) {
+ player.closeInventory();
+ }
+ }
+ }
+
+ private void loadConfigs() {
+ messages = loadConfig("messages.yml");
+ config = loadConfig("config.yml");
+ rankups = new Rankups(this, loadConfig("rankups.yml"));
+ }
+
+ private FileConfiguration loadConfig(String name) {
+ File file = new File(getDataFolder(), name);
+ if (!file.exists()) {
+ saveResource(name, false);
+ }
+ return YamlConfiguration.loadConfiguration(file);
+ }
+
+ private void registerRequirements() {
+ requirementRegistry.addRequirement(new MoneyRequirement(this, "money"));
+ requirementRegistry.addRequirement(new XpLevelRequirement(this, "xp-level"));
+ requirementRegistry.addRequirement(new PlaytimeHoursRequirement(this, "playtime-hours"));
+ }
+
+ private void setupPermissions() {
+ RegisteredServiceProvider rsp = getServer().getServicesManager().getRegistration(Permission.class);
+ permissions = rsp.getProvider();
+ }
+
+ private void setupEconomy() {
+ RegisteredServiceProvider rsp = getServer().getServicesManager().getRegistration(Economy.class);
+ if (rsp != null) {
+ economy = rsp.getProvider();
+ } else {
+ getLogger().warning("No economy found.");
+ }
+ }
+
+ public MessageBuilder getMessage(Rank rank, Message message) {
+ ConfigurationSection messages = rankups.getConfig()
+ .getConfigurationSection(rank.getName());
+ if(messages == null || !messages.isSet(message.getName())) {
+ messages = this.messages;
+ }
+ return MessageBuilder.of(messages, message);
+ }
+
+ public MessageBuilder getMessage(Message message) {
+ return MessageBuilder.of(messages, message);
+ }
+
+ public void rankup(Player player) {
+ if(!checkRankup(player)) {
+ return;
+ }
+
+ Rank oldRank = rankups.getRank(player);
+ Rank rank = rankups.nextRank(oldRank);
+
+ oldRank.applyRequirements(player);
+
+ permissions.playerRemoveGroup(null, player, oldRank.getRank());
+ permissions.playerAddGroup(null, player, rank.getRank());
+
+ getMessage(oldRank, Message.SUCCESS_PUBLIC)
+ .failIfEmpty()
+ .replaceAll(player, oldRank, rank)
+ .broadcast();
+ getMessage(oldRank, Message.SUCCESS_PRIVATE)
+ .failIfEmpty()
+ .replaceAll(player, oldRank, rank)
+ .send(player);
+
+ oldRank.runCommands(player, rank);
+ }
+
+ /**
+ * Checks if a player can rankup,
+ * and if they can't, sends the player a message and returns false
+ * @param player the player to check if they can rankup
+ * @return true if the player can rankup, false otherwise
+ */
+ public boolean checkRankup(Player player) {
+ Rank rank = rankups.getRank(player);
+ if (rank == null) { // check if in ladder
+ getMessage(Message.NOT_IN_LADDER)
+ .replace(Variable.PLAYER, player.getName())
+ .send(player);
+ return false;
+ } else if (rank.isLastRank()) { // check if they are at the highest rank
+ getMessage(rank, Message.NO_RANKUP)
+ .replaceAll(player, rank)
+ .send(player);
+ return false;
+ } else if (!rank.checkRequirements(player)) { // check if they can afford it
+ MessageBuilder builder =
+ getMessage(rank, Message.REQUIREMENTS_NOT_MET)
+ .replaceAll(player, rank);
+ if (economy != null) {
+ double balance = economy.getBalance(player);
+ builder = builder
+ .replace(Variable.MONEY, balance)
+ .replace(Variable.MONEY_NEEDED, rank.getRequirement("money").getAmount() - balance);
+ }
+ builder.send(player);
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/commands/InfoCommand.java b/src/main/java/sh/okx/rankup/commands/InfoCommand.java
new file mode 100644
index 0000000..4edc09e
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/commands/InfoCommand.java
@@ -0,0 +1,72 @@
+package sh.okx.rankup.commands;
+
+import com.google.common.base.Charsets;
+import com.google.common.io.CharStreams;
+import lombok.RequiredArgsConstructor;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.plugin.PluginDescriptionFile;
+import sh.okx.rankup.Rankup;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+
+@RequiredArgsConstructor
+public class InfoCommand implements CommandExecutor {
+ private String versionMessage;
+ private final Rankup plugin;
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ if(args.length > 0) {
+ if(args[0].equalsIgnoreCase("reload") && sender.hasPermission("rankup.reload")) {
+ plugin.reload();
+ sender.sendMessage(ChatColor.GREEN + "" + ChatColor.BOLD + "Rankup " + ChatColor.YELLOW + "Reloaded configuration files.");
+ return true;
+ }
+ }
+
+ PluginDescriptionFile description = plugin.getDescription();
+ sender.sendMessage(
+ ChatColor.GREEN + "" + ChatColor.BOLD + description.getName() + " " + description.getVersion() +
+ ChatColor.YELLOW + " by " + ChatColor.BLUE + ChatColor.BOLD + String.join(", ", description.getAuthors()));
+ if(sender.hasPermission("rankup.reload")) {
+ sender.sendMessage(ChatColor.GREEN + "/" + label + " reload " + ChatColor.YELLOW + "Reloads configuration files.");
+ }
+ if(sender.hasPermission("rankup.checkversion")) {
+ if(versionMessage == null) {
+ sender.sendMessage(ChatColor.YELLOW + "Checking version...");
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
+ String message;
+ try {
+ String latest = getLatestVersion();
+ if (description.getVersion().equals(latest)) {
+ message = ChatColor.GREEN + "You are on the latest version.";
+ } else {
+ message = ChatColor.YELLOW + "A new version is available: " + ChatColor.GOLD + latest
+ + "\nhttps://www.spigotmc.org/resources/rankup.17933/";
+ }
+ } catch (IOException e) {
+ message = ChatColor.RED + "Error while checking version.";
+ }
+ versionMessage = message;
+ Bukkit.getScheduler().runTask(plugin, () -> sender.sendMessage(versionMessage));
+ });
+ } else {
+ sender.sendMessage(versionMessage);
+ }
+ }
+
+ return true;
+ }
+
+ public String getLatestVersion() throws IOException {
+ URL url = new URL("https://api.spigotmc.org/legacy/update.php?resource=17933");
+ String result = CharStreams.toString(new InputStreamReader(url.openStream(), Charsets.UTF_8));
+ return result;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/commands/RankListCommand.java b/src/main/java/sh/okx/rankup/commands/RankListCommand.java
new file mode 100644
index 0000000..3c92ba3
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/commands/RankListCommand.java
@@ -0,0 +1,95 @@
+package sh.okx.rankup.commands;
+
+import lombok.RequiredArgsConstructor;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+import sh.okx.rankup.messages.Message;
+import sh.okx.rankup.messages.MessageBuilder;
+import sh.okx.rankup.messages.Variable;
+import sh.okx.rankup.ranks.Rank;
+import sh.okx.rankup.ranks.Rankups;
+import sh.okx.rankup.ranks.requirements.Requirement;
+
+import java.text.DecimalFormat;
+
+@RequiredArgsConstructor
+public class RankListCommand implements CommandExecutor {
+ private final Rankup plugin;
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ Rankups rankups = plugin.getRankups();
+ Rank playerRank = null;
+ if(sender instanceof Player) {
+ playerRank = rankups.getRank((Player) sender);
+ }
+
+ sendHeaderFooter(sender, playerRank, Message.RANKS_HEADER);
+
+ int state = playerRank == null ? 2 : 0;
+ Rank rank = rankups.getFirstRank();
+ do {
+ Rank next = rankups.nextRank(rank);
+ if(rank.equals(playerRank)) {
+ sendMessage(sender, 1, rank, next);
+ state = 2;
+ } else {
+ sendMessage(sender, state, rank, next);
+ }
+ rank = next;
+ } while(!rank.isLastRank());
+
+ sendHeaderFooter(sender, playerRank, Message.RANKS_FOOTER);
+ return true;
+ }
+
+ private void sendHeaderFooter(CommandSender sender, Rank rank, Message type) {
+ MessageBuilder builder = plugin.getMessage(type)
+ .failIfEmpty();
+ if(rank == null) {
+ builder.replace(Variable.PLAYER, sender.getName());
+ } else {
+ builder.replaceAll(sender, rank);
+ }
+ builder.send(sender);
+ }
+
+ private void sendMessage(CommandSender player, int state, Rank oldRank, Rank rank) {
+ if(state == 0) {
+ replaceCost(plugin.getMessage(oldRank, Message.RANKS_COMPLETE)
+ .replaceAll(player, oldRank, rank), player, oldRank)
+ .send(player);
+ } else if(state == 1) {
+ replaceCost(plugin.getMessage(oldRank, Message.RANKS_CURRENT)
+ .replaceAll(player, oldRank, rank), player, oldRank)
+ .send(player);
+ } else if(state == 2) {
+ replaceCost(plugin.getMessage(oldRank, Message.RANKS_INCOMPLETE)
+ .replaceAll(player, oldRank, rank), player, oldRank)
+ .send(player);
+ }
+ }
+
+ private MessageBuilder replaceCost(MessageBuilder builder, CommandSender sender, Rank rank) {
+ Requirement money = rank.getRequirement("money");
+ if(money == null || plugin.getEconomy() == null) {
+ return builder;
+ }
+ double amount;
+ if(sender instanceof Player && rank.isInRank((Player) sender)) {
+ amount = money.getRemaining((Player) sender);
+ } else {
+ amount = money.getAmount();
+ }
+ DecimalFormat moneyFormat = plugin.getPlaceholders().getMoneyFormat();
+ DecimalFormat percentFormat = plugin.getPlaceholders().getPercentFormat();
+ return builder
+ .replace(Variable.MONEY_NEEDED, moneyFormat.format(amount))
+ .replace(Variable.PERCENT_LEFT, percentFormat.format((amount / money.getAmount()) * 100))
+ .replace(Variable.PERCENT_DONE, percentFormat.format((1-(amount / money.getAmount())) * 100))
+ .replace(Variable.MONEY, moneyFormat.format(money.getAmount()));
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/commands/RankupCommand.java b/src/main/java/sh/okx/rankup/commands/RankupCommand.java
new file mode 100644
index 0000000..6467d95
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/commands/RankupCommand.java
@@ -0,0 +1,72 @@
+package sh.okx.rankup.commands;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+import sh.okx.rankup.gui.Gui;
+import sh.okx.rankup.messages.Message;
+import sh.okx.rankup.messages.Variable;
+import sh.okx.rankup.ranks.Rank;
+import sh.okx.rankup.ranks.Rankups;
+
+import java.util.Map;
+import java.util.WeakHashMap;
+
+public class RankupCommand implements CommandExecutor {
+ private final Map confirming = new WeakHashMap<>();
+ private final Rankup plugin;
+
+ public RankupCommand(Rankup plugin) {
+ this.plugin = plugin;
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ // check if player
+ if (!(sender instanceof Player)) {
+ return false;
+ }
+ Player player = (Player) sender;
+
+ Rankups rankups = plugin.getRankups();
+ Rank rank = rankups.getRank(player);
+ if (!plugin.checkRankup(player)) {
+ return true;
+ }
+
+ FileConfiguration config = plugin.getConfig();
+ String confirmationType = config.getString("confirmation-type").toLowerCase();
+
+ // if they are on text confirming, rank them up
+ if (confirmationType.equals("text") && confirming.containsKey(player)) {
+ long time = System.currentTimeMillis() - confirming.remove(player);
+ if (time < config.getInt("text.timeout") * 1000) {
+ plugin.rankup(player);
+ return true;
+ }
+ }
+
+ switch (confirmationType) {
+ case "text":
+ confirming.put(player, System.currentTimeMillis());
+ plugin.getMessage(rank, Message.CONFIRMATION)
+ .replace(Variable.PLAYER, player.getName())
+ .replace(Variable.RANK, rank.getRank())
+ .replace(Variable.RANK_NAME, rank.getName())
+ .send(player);
+ break;
+ case "gui":
+ Gui.of(player, rank, rankups.nextRank(rank), plugin).open(player);
+ break;
+ case "none":
+ plugin.rankup(player);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid confirmation type " + confirmationType);
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/gui/Gui.java b/src/main/java/sh/okx/rankup/gui/Gui.java
new file mode 100644
index 0000000..555765b
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/gui/Gui.java
@@ -0,0 +1,121 @@
+package sh.okx.rankup.gui;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.InventoryHolder;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import sh.okx.rankup.Rankup;
+import sh.okx.rankup.messages.Message;
+import sh.okx.rankup.messages.MessageBuilder;
+import sh.okx.rankup.messages.Variable;
+import sh.okx.rankup.ranks.Rank;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class Gui implements InventoryHolder {
+ @Getter
+ private Inventory inventory;
+ @Getter
+ private ItemStack rankup;
+ @Getter
+ private ItemStack cancel;
+
+ public void open(Player player) {
+ player.openInventory(inventory);
+ }
+
+ public static Gui of(Player player, Rank oldRank, Rank rank, Rankup plugin) {
+ ConfigurationSection config = plugin.getConfig().getConfigurationSection("gui");
+ ItemStack[] items = new ItemStack[config.getInt("rows") * 9];
+ addItem(items, config.getConfigurationSection("rankup"), player, oldRank, rank);
+ addItem(items, config.getConfigurationSection("cancel"), player, oldRank, rank);
+ addItem(items, config.getConfigurationSection("fill"), player, oldRank, rank);
+
+ Gui gui = new Gui();
+ gui.rankup = getItem(config.getConfigurationSection("rankup"), player, oldRank, rank);
+ gui.cancel = getItem(config.getConfigurationSection("cancel"), player, oldRank, rank);
+ Inventory inventory = Bukkit.createInventory(gui,
+ items.length,
+ plugin.getMessage(rank, Message.TITLE)
+ .replaceAll(player, oldRank, rank)
+ .toString());
+ inventory.setContents(items);
+ gui.inventory = inventory;
+ return gui;
+ }
+
+ private static ItemStack getItem(ConfigurationSection section, Player player, Rank oldRank, Rank rank) {
+ boolean legacy = !Bukkit.getVersion().contains("1.13");
+
+ String materialName = section.getString("material").toUpperCase();
+ // handle default material correctly on older versions
+ if (legacy && materialName.equals("BLACK_STAINED_GLASS_PANE")) {
+ materialName = "STAINED_GLASS_PANE:15";
+ }
+
+ ItemStack item;
+ if (legacy) {
+ String[] parts = materialName.split(":");
+ Material material = Material.valueOf(parts[0]);
+
+ short type = parts.length > 1 ? Short.parseShort(parts[1]) : 0;
+ item = new ItemStack(material, 1, type);
+ } else {
+ Material material = Material.valueOf(materialName);
+ item = new ItemStack(material);
+ }
+
+ ItemMeta meta = item.getItemMeta();
+ if (section.contains("lore")) {
+ meta.setLore(Arrays.stream(format(section.getString("lore"), player, oldRank, rank).split("\n"))
+ .map(string -> ChatColor.RESET + string)
+ .collect(Collectors.toList()));
+ }
+ if (section.contains("name")) {
+ meta.setDisplayName(ChatColor.RESET + format(section.getString("name"), player, oldRank, rank));
+ }
+ item.setItemMeta(meta);
+
+ return item;
+ }
+
+ private static String format(String message, Player player, Rank oldRank, Rank rank) {
+ return new MessageBuilder(ChatColor.translateAlternateColorCodes('&', message))
+ .replaceAll(player, oldRank, rank)
+ .toString();
+ }
+
+ private static void addItem(ItemStack[] items, ConfigurationSection section, Player player, Rank oldRank, Rank rank) {
+ ItemStack item = getItem(section, player, oldRank, rank);
+ if (section.getName().equalsIgnoreCase("fill")) {
+ for (int i = 0; i < items.length; i++) {
+ if (items[i] == null) {
+ items[i] = item;
+ }
+ }
+ return;
+ }
+
+ String[] locations = section.getString("index").split(" ");
+ for (String location : locations) {
+ String[] parts = location.split("-");
+ if (parts.length == 1) {
+ items[Integer.parseInt(parts[0])] = item;
+ } else {
+ for (int i = Integer.parseInt(parts[0]); i <= Integer.parseInt(parts[1]); i++) {
+ items[i] = item;
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/gui/GuiListener.java b/src/main/java/sh/okx/rankup/gui/GuiListener.java
new file mode 100644
index 0000000..e4897b3
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/gui/GuiListener.java
@@ -0,0 +1,36 @@
+package sh.okx.rankup.gui;
+
+import lombok.RequiredArgsConstructor;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import sh.okx.rankup.Rankup;
+
+@RequiredArgsConstructor
+public class GuiListener implements Listener {
+ private final Rankup plugin;
+
+ @EventHandler
+ public void on(InventoryClickEvent e) {
+ Inventory inventory = e.getInventory();
+ if (inventory == null
+ || !(inventory.getHolder() instanceof Gui)
+ || !e.getInventory().equals(e.getClickedInventory())) {
+ return;
+ }
+ e.setCancelled(true);
+
+ Player player = (Player) e.getWhoClicked();
+ Gui gui = (Gui) inventory.getHolder();
+
+ if (gui.getRankup().isSimilar(e.getCurrentItem())) {
+ Bukkit.getScheduler().runTask(plugin, player::closeInventory);
+ plugin.rankup(player);
+ } else if (gui.getCancel().isSimilar(e.getCurrentItem())) {
+ Bukkit.getScheduler().runTask(plugin, player::closeInventory);
+ }
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/messages/EmptyMessageBuilder.java b/src/main/java/sh/okx/rankup/messages/EmptyMessageBuilder.java
new file mode 100644
index 0000000..9039317
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/messages/EmptyMessageBuilder.java
@@ -0,0 +1,32 @@
+package sh.okx.rankup.messages;
+
+import org.bukkit.command.CommandSender;
+
+class EmptyMessageBuilder extends MessageBuilder {
+ EmptyMessageBuilder() {
+ super(null);
+ }
+
+ @Override
+ public MessageBuilder failIfEmpty() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public MessageBuilder replace(Variable variable, Object value) {
+ return this;
+ }
+
+ @Override
+ public void send(CommandSender sender) {
+ }
+
+ @Override
+ public void broadcast() {
+ }
+
+ @Override
+ public String toString() {
+ return null;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/messages/Message.java b/src/main/java/sh/okx/rankup/messages/Message.java
new file mode 100644
index 0000000..f20813f
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/messages/Message.java
@@ -0,0 +1,25 @@
+package sh.okx.rankup.messages;
+
+import lombok.Getter;
+
+public enum Message {
+ NOT_IN_LADDER("not-in-ladder"),
+ REQUIREMENTS_NOT_MET("rankup.requirements-not-met"),
+ NO_RANKUP("rankup.no-rankup"),
+ SUCCESS_PUBLIC("rankup.success-public"),
+ SUCCESS_PRIVATE("rankup.success-private"),
+ CONFIRMATION("rankup.confirmation"),
+ TITLE("rankup.title"),
+ RANKS_HEADER("ranks.header"),
+ RANKS_FOOTER("ranks.footer"),
+ RANKS_COMPLETE("rankup.ranks.complete"),
+ RANKS_CURRENT("rankup.ranks.current"),
+ RANKS_INCOMPLETE("rankup.ranks.incomplete");
+
+ @Getter
+ private final String name;
+
+ Message(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/messages/MessageBuilder.java b/src/main/java/sh/okx/rankup/messages/MessageBuilder.java
new file mode 100644
index 0000000..e522e30
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/messages/MessageBuilder.java
@@ -0,0 +1,103 @@
+package sh.okx.rankup.messages;
+
+import lombok.AllArgsConstructor;
+import net.milkbowl.vault.economy.Economy;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.ranks.Rank;
+import sh.okx.rankup.ranks.requirements.Requirement;
+
+import java.text.DecimalFormat;
+
+public class MessageBuilder {
+ private String message;
+
+ public MessageBuilder(String message) {
+ this.message = message;
+ }
+
+ public static MessageBuilder of(ConfigurationSection config, Message message) {
+ return new MessageBuilder(ChatColor.translateAlternateColorCodes('&', config.getString(message.getName())));
+ }
+
+ public MessageBuilder replace(Variable variable, Object value) {
+ this.message = variable.replace(message, String.valueOf(value));
+ return this;
+ }
+
+ public MessageBuilder replaceAll(CommandSender player, Rank rank) {
+ replace(Variable.PLAYER, player.getName());
+ replaceAll(rank);
+ return this;
+ }
+
+ public MessageBuilder replaceAll(CommandSender player, Rank oldRank, Rank rank) {
+ replace(Variable.PLAYER, player.getName());
+ replaceAll(oldRank, rank);
+ return this;
+ }
+
+ public MessageBuilder replaceAll(Rank rank) {
+ replace(Variable.RANK, rank.getRank());
+ replace(Variable.RANK_NAME, rank.getName());
+ return this;
+ }
+
+ public MessageBuilder replaceAll(Rank oldRank, Rank rank) {
+ replace(Variable.RANK, rank.getRank());
+ replace(Variable.RANK_NAME, rank.getName());
+ replace(Variable.OLD_RANK, oldRank.getRank());
+ replace(Variable.OLD_RANK_NAME, oldRank.getName());
+ return this;
+ }
+
+ public MessageBuilder replaceCost(CommandSender sender, Economy economy, Rank rank) {
+ Requirement money = rank.getRequirement("money");
+ if(money == null || economy == null) {
+ return this;
+ }
+ replace(Variable.MONEY, money.getAmount());
+ if(sender instanceof Player && rank.isInRank((Player) sender)) {
+ replace(Variable.MONEY_NEEDED, money.getRemaining((Player) sender));
+ } else {
+ replace(Variable.MONEY_NEEDED, money.getAmount());
+ }
+ return this;
+ }
+
+ /**
+ * Fails the MessageBuilder if the message is empty.
+ * if this fails, all subsequent calls to that MessageBuilder will do nothing
+ * @return an EmptyMessageBuilder if the message is empty, itself otherwise
+ */
+ public MessageBuilder failIfEmpty() {
+ if(message.isEmpty()) {
+ return new EmptyMessageBuilder();
+ } else {
+ return this;
+ }
+ }
+
+ public void send(CommandSender sender) {
+ sender.sendMessage(message);
+ }
+
+ /**
+ * Sends the message to all players
+ * ie, calls MessageBuilder#send(Player) for all players online, and sends the message in the console.
+ */
+ public void broadcast() {
+ for(Player player : Bukkit.getOnlinePlayers()) {
+ send(player);
+ }
+ send(Bukkit.getConsoleSender());
+ }
+
+ @Override
+ public String toString() {
+ return message;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/messages/Variable.java b/src/main/java/sh/okx/rankup/messages/Variable.java
new file mode 100644
index 0000000..d3f18bf
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/messages/Variable.java
@@ -0,0 +1,31 @@
+package sh.okx.rankup.messages;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public enum Variable {
+ PLAYER,
+ OLD_RANK,
+ OLD_RANK_NAME,
+ RANK,
+ RANK_NAME,
+ MONEY,
+ MONEY_NEEDED,
+ PERCENT_DONE,
+ PERCENT_LEFT;
+
+ public static Variable getVariable(String name) {
+ for(Variable variable : values()) {
+ if(variable.toString().equalsIgnoreCase(name)) {
+ return variable;
+ }
+ }
+ return null;
+ }
+
+ public String replace(String message, String value) {
+ Pattern pattern = Pattern.compile("\\{" + this + "}", Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(message);
+ return matcher.replaceAll(value);
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/placeholders/Placeholders.java b/src/main/java/sh/okx/rankup/placeholders/Placeholders.java
new file mode 100644
index 0000000..d2f56f7
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/placeholders/Placeholders.java
@@ -0,0 +1,167 @@
+package sh.okx.rankup.placeholders;
+
+import lombok.Getter;
+import me.clip.placeholderapi.expansion.PlaceholderExpansion;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+import sh.okx.rankup.ranks.Rank;
+import sh.okx.rankup.ranks.Rankups;
+import sh.okx.rankup.ranks.requirements.Requirement;
+
+import java.text.DecimalFormat;
+import java.util.function.Function;
+
+public class Placeholders extends PlaceholderExpansion {
+ private final Rankup plugin;
+ @Getter
+ private final DecimalFormat moneyFormat;
+ @Getter
+ private final DecimalFormat percentFormat;
+ @Getter
+ private final DecimalFormat simpleFormat;
+
+ public Placeholders(Rankup plugin) {
+ this.plugin = plugin;
+ this.moneyFormat = new DecimalFormat(plugin.getConfig().getString("placeholders.money-format"));
+ this.percentFormat = new DecimalFormat(plugin.getConfig().getString("placeholders.percent-format"));
+ this.simpleFormat = new DecimalFormat(plugin.getConfig().getString("placeholders.simple-format"));
+ }
+
+ @Override
+ public String onPlaceholderRequest(Player player, String params) {
+ if (player == null) {
+ return "";
+ }
+ params = params.toLowerCase();
+
+ Rankups rankups = plugin.getRankups();
+ Rank rank = rankups.getRank(player);
+ Rank next = null;
+ if (rank != null) {
+ next = rankups.nextRank(rank);
+ }
+
+ if(params.startsWith("requirement_")) {
+ String[] parts = params.split("_", 3);
+ return getPlaceholderRequirement(player, rank,
+ parts[1], parts.length > 2 ? parts[2] : "");
+ }
+
+ switch (params) {
+ case "current_rank":
+ return orElsePlaceholder(rank, Rank::getRank, "not-in-ladder");
+ case "current_rank_name":
+ return orElsePlaceholder(rank, Rank::getRank, "not-in-ladder");
+ case "current_rank_money":
+ return orElsePlaceholder(rank, r -> simplify(r.getRequirement("money").getAmount()), 0);
+ case "current_rank_money_formatted":
+ return moneyFormat.format(orElsePlaceholder(rank, r -> r.getRequirement("money").getAmount(), 0));
+ case "next_rank":
+ if (rank == null) {
+ return getPlaceholder("not-in-ladder");
+ } else if (next == null) {
+ return getPlaceholder("highest-rank");
+ } else {
+ return next.getRank();
+ }
+ case "next_rank_name":
+ if (rank == null) {
+ return getPlaceholder("not-in-ladder");
+ } else if (next == null) {
+ return getPlaceholder("highest-rank");
+ } else {
+ return next.getName();
+ }
+ case "next_rank_money":
+ return orElsePlaceholder(next, r -> simplify(r.getRequirement("money").getAmount()), 0);
+ case "next_rank_money_formatted":
+ return moneyFormat.format(orElsePlaceholder(next, r -> r.getRequirement("money").getAmount(), 0));
+ case "next_rank_money_left":
+ return orElsePlaceholder(next, r -> simplify(plugin.getEconomy().getBalance(player) - r.getRequirement("money").getAmount()), 0);
+ case "next_rank_money_left_formatted":
+ return moneyFormat.format(orElsePlaceholder(next, r -> plugin.getEconomy().getBalance(player) - r.getRequirement("money").getAmount(), 0));
+ case "next_rank_percent_left":
+ return orElsePlaceholder(next, r -> (1-(plugin.getEconomy().getBalance(player) / r.getRequirement("money").getAmount())) * 100, 0);
+ case "next_rank_percent_left_formatted":
+ return percentFormat.format(orElsePlaceholder(next, r -> (1-(plugin.getEconomy().getBalance(player) / r.getRequirement("money").getAmount())) * 100, 0));
+ case "next_rank_percent_done":
+ return orElsePlaceholder(next, r -> (plugin.getEconomy().getBalance(player) / r.getRequirement("money").getAmount()) * 100, 0);
+ case "next_rank_percent_done_formatted":
+ return percentFormat.format(orElsePlaceholder(next, r -> (plugin.getEconomy().getBalance(player) / r.getRequirement("money").getAmount()) * 100, 0));
+ default:
+ return null;
+ }
+ }
+
+ private String getPlaceholderRequirement(Player player, Rank rank, String requirementName, String params) {
+ if(rank == null) {
+ return "";
+ }
+ Requirement requirement = rank.getRequirement(requirementName);
+ switch(params) {
+ case "":
+ return simpleFormat.format(orElse(requirement, Requirement::getAmount, 0));
+ case "left":
+ return simpleFormat.format(orElse(requirement, r -> r.getRemaining(player), 0));
+ case "percent_left":
+ return percentFormat.format(orElse(requirement, r -> (r.getRemaining(player) / r.getAmount()) * 100, 0));
+ case "percent_done":
+ return percentFormat.format(orElse(requirement, r -> (1-(r.getRemaining(player) / r.getAmount())) * 100, 100));
+ default:
+ return null;
+ }
+ }
+
+ private Number simplify(Number number) {
+ if (number instanceof Float) {
+ return (float) number % 1 == 0 ? (int) number : number;
+ } else if (number instanceof Double) {
+ return (double) number % 1 == 0 ? (long) number : number;
+ } else {
+ return number;
+ }
+ }
+
+ private String orElsePlaceholder(T t, Function value, Object fallback) {
+ if (t == null) {
+ return getPlaceholder(String.valueOf(fallback));
+ }
+
+ try {
+ return String.valueOf(value.apply(t));
+ } catch (NullPointerException ex) {
+ return getPlaceholder(String.valueOf(fallback));
+ }
+ }
+
+ private R orElse(T t, Function value, R fallback) {
+ if (t == null) {
+ return fallback;
+ }
+
+ try {
+ return value.apply(t);
+ } catch (NullPointerException ex) {
+ return fallback;
+ }
+ }
+
+ private String getPlaceholder(String name) {
+ return plugin.getConfig().getString("placeholders." + name);
+ }
+
+ @Override
+ public String getIdentifier() {
+ return "rankup";
+ }
+
+ @Override
+ public String getAuthor() {
+ return String.join(", ", plugin.getDescription().getAuthors());
+ }
+
+ @Override
+ public String getVersion() {
+ return plugin.getDescription().getVersion();
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/Prestige.java b/src/main/java/sh/okx/rankup/ranks/Prestige.java
new file mode 100644
index 0000000..fe006cb
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/Prestige.java
@@ -0,0 +1,4 @@
+package sh.okx.rankup.ranks;
+
+public class Prestige {
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/Rank.java b/src/main/java/sh/okx/rankup/ranks/Rank.java
new file mode 100644
index 0000000..9c5106b
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/Rank.java
@@ -0,0 +1,138 @@
+package sh.okx.rankup.ranks;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.bukkit.Bukkit;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+import sh.okx.rankup.messages.MessageBuilder;
+import sh.okx.rankup.messages.Variable;
+import sh.okx.rankup.ranks.requirements.Requirement;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BinaryOperator;
+
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public class Rank {
+ private final Rankup plugin;
+ @Getter
+ private final String name;
+ @Getter
+ private final String next;
+ @Getter
+ private final String rank;
+ private final Set requirements;
+ private final BinaryOperator reducer;
+ private final List commands;
+
+ public static Rank deserialize(Rankup plugin, ConfigurationSection section) {
+ String rank = section.getString("rank");
+
+ Set requirements = new HashSet<>();
+ BinaryOperator reducer = null;
+ ConfigurationSection requirementsSection = section.getConfigurationSection("requirements");
+ if(requirementsSection != null) {
+ for (Map.Entry entry : requirementsSection.getValues(false).entrySet()) {
+ String name = entry.getKey();
+ double amount = Double.parseDouble(String.valueOf(entry.getValue()));
+
+ Requirement requirement = plugin.getRequirementRegistry().newRequirement(name, amount);
+ if (requirement == null) {
+ plugin.getLogger().warning("Unknown requirement " + name);
+ } else {
+ requirements.add(requirement);
+ }
+ }
+
+ String operation = section.getString("operation");
+ if (operation == null) {
+ operation = "and";
+ }
+ switch (operation) {
+ case "and":
+ reducer = (a, b) -> a && b;
+ break;
+ case "or":
+ reducer = (a, b) -> a || b;
+ break;
+ case "xor":
+ reducer = (a, b) -> (a && !b) || (b && !a);
+ break;
+ case "none":
+ reducer = (a, b) -> !a && !b;
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid operation type for rank " + rank);
+ }
+ }
+
+ return new Rank(plugin,
+ section.getName(),
+ section.getString("next"),
+ rank,
+ requirements,
+ reducer,
+ section.getStringList("commands"));
+ }
+
+ public boolean checkRequirements(Player player) {
+ return requirements.stream()
+ .map(requirement -> requirement.check(player))
+ .reduce(reducer)
+ .orElse(true);
+ }
+
+ public boolean isInRank(Player player) {
+ String[] groups = plugin.getPermissions().getPlayerGroups(player);
+ for (String group : groups) {
+ if(group.equalsIgnoreCase(rank)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean isLastRank() {
+ return next == null;
+ }
+
+ public Requirement getRequirement(String name) {
+ for(Requirement requirement : requirements) {
+ if(requirement.getName().equalsIgnoreCase(name)) {
+ return requirement;
+ }
+ }
+ return null;
+ }
+
+ public void applyRequirements(Player player) {
+ for(Requirement requirement : requirements) {
+ requirement.apply(player);
+ }
+ }
+
+ public void runCommands(Player player, Rank nextRank) {
+ for (String command : commands) {
+ Bukkit.dispatchCommand(Bukkit.getConsoleSender(), new MessageBuilder(command)
+ .replace(Variable.PLAYER, player.getName())
+ .replace(Variable.OLD_RANK, rank)
+ .replace(Variable.OLD_RANK_NAME, name)
+ .replace(Variable.RANK, nextRank.rank)
+ .replace(Variable.RANK_NAME, nextRank.name)
+ .toString());
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if(!(o instanceof Rank)) {
+ return false;
+ }
+ return ((Rank) o).name.equals(name);
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/Rankups.java b/src/main/java/sh/okx/rankup/ranks/Rankups.java
new file mode 100644
index 0000000..e36803e
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/Rankups.java
@@ -0,0 +1,80 @@
+package sh.okx.rankup.ranks;
+
+import lombok.Getter;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+public class Rankups {
+ @Getter
+ private final FileConfiguration config;
+ private final Set ranks = new HashSet<>();
+
+ public Rankups(Rankup plugin, FileConfiguration config) {
+ this.config = config;
+ for (Map.Entry entry : config.getValues(false).entrySet()) {
+ ConfigurationSection rankSection = (ConfigurationSection) entry.getValue();
+ ranks.add(Rank.deserialize(plugin, rankSection));
+ }
+ }
+
+ public Rank getFirstRank() {
+ OUTER:
+ for(Rank rank : ranks) {
+ // see if anything ranks up to this
+ for(Rank rank0 : ranks) {
+ if(!rank0.isLastRank() && rank0.getNext().equals(rank.getName())) {
+ continue OUTER;
+ }
+ }
+ // nothing ranks up to this
+ return rank;
+ }
+ return null;
+ }
+
+ public Rank getRank(String name) {
+ for(Rank rank : ranks) {
+ if(rank.getName().equals(name)) {
+ return rank;
+ }
+ }
+ return null;
+ }
+
+ public Rank getRank(Player player) {
+ return ranks.stream()
+ .filter(rank -> rank.isInRank(player))
+ .findFirst()
+ .orElse(null);
+ }
+
+ public Rank nextRank(Rank rank) {
+ if(rank.isLastRank()) {
+ return null;
+ }
+
+ for(Rank nextRank : ranks) {
+ if (rank.getNext().equalsIgnoreCase(nextRank.getName())) {
+ return nextRank;
+ }
+ }
+ // this shouldn't happen but whatever
+ return null;
+ }
+
+// public boolean hasNext(Rank start, Rank rank) {
+// while(!start.isLastRank()) {
+// start = nextRank(rank);
+// if(start.equals(rank)) {
+// return true;
+// }
+// }
+// return false;
+// }
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/requirements/MoneyRequirement.java b/src/main/java/sh/okx/rankup/ranks/requirements/MoneyRequirement.java
new file mode 100644
index 0000000..74bf324
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/requirements/MoneyRequirement.java
@@ -0,0 +1,38 @@
+package sh.okx.rankup.ranks.requirements;
+
+import net.milkbowl.vault.economy.Economy;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+
+public class MoneyRequirement extends Requirement {
+ public MoneyRequirement(Rankup plugin, String name) {
+ super(plugin, name);
+ }
+
+ protected MoneyRequirement(Requirement clone) {
+ super(clone);
+ }
+
+ @Override
+ public boolean check(Player player) {
+ Economy economy = plugin.getEconomy();
+ double balance = economy.getBalance(player);
+ return balance >= amount;
+ }
+
+ @Override
+ public void apply(Player player) {
+ Economy economy = plugin.getEconomy();
+ economy.withdrawPlayer(player, amount);
+ }
+
+ @Override
+ public double getRemaining(Player player) {
+ return Math.max(0, amount - plugin.getEconomy().getBalance(player));
+ }
+
+ @Override
+ public Requirement clone() {
+ return new MoneyRequirement(this);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/sh/okx/rankup/ranks/requirements/PlaytimeHoursRequirement.java b/src/main/java/sh/okx/rankup/ranks/requirements/PlaytimeHoursRequirement.java
new file mode 100644
index 0000000..1406f30
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/requirements/PlaytimeHoursRequirement.java
@@ -0,0 +1,37 @@
+package sh.okx.rankup.ranks.requirements;
+
+import org.bukkit.Statistic;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+
+public class PlaytimeHoursRequirement extends Requirement {
+ private static final int TICKS_PER_HOUR = 20 * 60 * 60;
+
+ public PlaytimeHoursRequirement(Rankup plugin, String name) {
+ super(plugin, name);
+ }
+
+ protected PlaytimeHoursRequirement(Requirement clone) {
+ super(clone);
+ }
+
+ @Override
+ public boolean check(Player player) {
+ return player.getStatistic(Statistic.PLAY_ONE_MINUTE) * TICKS_PER_HOUR >= amount;
+ }
+
+ @Override
+ public void apply(Player player) {
+ // well, we can't really take hours of playtime away, can we?
+ }
+
+ @Override
+ public double getRemaining(Player player) {
+ return amount - (player.getStatistic(Statistic.PLAY_ONE_MINUTE) * TICKS_PER_HOUR);
+ }
+
+ @Override
+ public Requirement clone() {
+ return new PlaytimeHoursRequirement(this);
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/requirements/Requirement.java b/src/main/java/sh/okx/rankup/ranks/requirements/Requirement.java
new file mode 100644
index 0000000..8d26188
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/requirements/Requirement.java
@@ -0,0 +1,55 @@
+package sh.okx.rankup.ranks.requirements;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+
+public abstract class Requirement implements Cloneable {
+ protected Rankup plugin;
+ @Getter
+ protected String name;
+ @Getter
+ @Setter
+ protected double amount;
+
+ public Requirement(Rankup plugin, String name) {
+ this.plugin = plugin;
+ this.name = name;
+ }
+
+ protected Requirement(Requirement clone) {
+ if(clone != null) {
+ this.plugin = clone.plugin;
+ this.name = clone.name;
+ this.amount = clone.amount;
+ }
+ }
+
+ /**
+ * Check if a player meets this requirement
+ * @param player the player to check
+ * @return true if they meet the requirement, false otherwise
+ */
+ public abstract boolean check(Player player);
+
+ /**
+ * Apply the effect of this requirement to the player.
+ * For money, this could be taking money away from the player.
+ * You can assume that Requirement#check(Player) has been called,
+ * and has returned true immediately prior to this.
+ * @param player the player to take from
+ */
+ public abstract void apply(Player player);
+
+ /**
+ * Get the remaining amount needed for Requirement#check(Player) to yield true.
+ * This is not required and is only used in placeholders.
+ * @param player the player to find the remaining amount of
+ * @return the remaining amount needed. Should be non-negative.
+ */
+ public double getRemaining(Player player) {
+ return amount;
+ }
+ public abstract Requirement clone();
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/requirements/RequirementRegistry.java b/src/main/java/sh/okx/rankup/ranks/requirements/RequirementRegistry.java
new file mode 100644
index 0000000..69ef0ff
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/requirements/RequirementRegistry.java
@@ -0,0 +1,23 @@
+package sh.okx.rankup.ranks.requirements;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public class RequirementRegistry {
+ private Set requirements = new HashSet<>();
+
+ public void addRequirement(Requirement requirement) {
+ requirements.add(requirement);
+ }
+
+ public Requirement newRequirement(String name, double amount) {
+ for(Requirement requirement : requirements) {
+ if(requirement.getName().equalsIgnoreCase(name)) {
+ Requirement newRequirement = requirement.clone();
+ newRequirement.setAmount(amount);
+ return newRequirement;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/sh/okx/rankup/ranks/requirements/XpLevelRequirement.java b/src/main/java/sh/okx/rankup/ranks/requirements/XpLevelRequirement.java
new file mode 100644
index 0000000..f4bed0d
--- /dev/null
+++ b/src/main/java/sh/okx/rankup/ranks/requirements/XpLevelRequirement.java
@@ -0,0 +1,40 @@
+package sh.okx.rankup.ranks.requirements;
+
+import org.bukkit.entity.Player;
+import sh.okx.rankup.Rankup;
+
+public class XpLevelRequirement extends Requirement {
+ public XpLevelRequirement(Rankup plugin, String name) {
+ super(plugin, name);
+ }
+
+ protected XpLevelRequirement(Requirement clone) {
+ super(clone);
+ }
+
+ @Override
+ public void setAmount(double amount) {
+ // experience level should be a whole number
+ super.setAmount(Math.round(amount));
+ }
+
+ @Override
+ public boolean check(Player player) {
+ return player.getLevel() >= amount;
+ }
+
+ @Override
+ public void apply(Player player) {
+ player.setLevel(player.getLevel() - (int) amount);
+ }
+
+ @Override
+ public double getRemaining(Player player) {
+ return Math.max(0, amount - player.getLevel());
+ }
+
+ @Override
+ public Requirement clone() {
+ return new XpLevelRequirement(this);
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
new file mode 100644
index 0000000..b15deda
--- /dev/null
+++ b/src/main/resources/config.yml
@@ -0,0 +1,45 @@
+# this is used for letting you know that you need to update/change your config file
+version: 0
+
+# how people should confirm ranking up
+# options are: gui, text or none
+confirmation-type: 'gui'
+
+gui:
+ rows: 1
+ rankup:
+ material: EMERALD_BLOCK
+ # index can be separated by spaces to show in multiple ways
+ # ie, 0-3 9-12 18-21
+ # you can also just use a single number instead of a range.
+ index: 0-3
+ name: '&a&lConfirm'
+ # lore is optional
+ lore: '&6Rankup to &b{RANK}'
+ cancel:
+ material: REDSTONE_BLOCK
+ index: 5-8
+ name: '&c&lCancel'
+ fill:
+ name: ' '
+ # if you are using a 1.8-1.12 and you want to change this
+ # you can use MATERIAL:data, for example STAINED_GLASS_PANE:8
+ # this works for both the rankup and cancel blocks as well
+ material: BLACK_STAINED_GLASS_PANE
+
+text:
+ # the time in seconds for a player to
+ # confirm ranking up by typing the command again
+ timeout: 10
+
+placeholders:
+ # format for money. for more information, see
+ # https://docs.oracle.com/javase/8/docs/api/java/text/DecimalFormat.html
+ money-format: "#,##0.##"
+ percent-format: "0.##"
+ # the format used for requirements
+ simple-format: "#.##"
+ # used for current_rank and next_rank placeholders when a player not in anything in rankups.yml
+ not-in-ladder: "None"
+ # used in next_rank placeholders when there is no rankup
+ highest-rank: "None"
\ No newline at end of file
diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml
new file mode 100644
index 0000000..b94341e
--- /dev/null
+++ b/src/main/resources/messages.yml
@@ -0,0 +1,35 @@
+# the messages in this section can be customised for each rankup in rankups.yml.
+rankup:
+ # NOTE: if you are using requirements for your ranks that are NOT money,
+ # you will want to change this for each rank!
+ requirements-not-met: "&cYou need {MONEY} money to rankup."
+ no-rankup: "&eYou are at the highest rank."
+ # set to an empty string, ie: success-public: ""
+ # to hide that message.
+ success-public: "&a{PLAYER} &ehas ranked up to: &d{RANK}"
+ success-private: "&aYou have ranked up to: &d{RANK}"
+ # used for the text confirmation
+ confirmation: |-
+ &eAre you sure you want to rankup to &a{RANK}&e?
+ &eType &c/rankup &eagain to confirm.
+ # used for the GUI confirmation
+ title: "Rankup to {RANK}"
+
+ # It is HIGHLY RECOMMENDED you override these in rankups.yml
+ # to show the specific requirements for each rank.
+ # however if you are just using money, you can use {MONEY} or {MONEY_NEEDED} or {PERCENT_DONE} or {PERCENT_LEFT}
+ # for example:
+ #ranks:
+ # complete: "&7{OLD_RANK} &8\xbb &7{RANK} &efor &7${MONEY}"
+ # current: "&c{OLD_RANK} &e\xbb &c{RANK} &efor &a${MONEY} &e{PERCENT_DONE}%"
+ # incomplete: "&r{OLD_RANK} &e\xbb &r{RANK} &efor &a${MONEY}"
+ ranks:
+ complete: "&7{OLD_RANK} &8\xbb &7{RANK}"
+ current: "&c{OLD_RANK} &e\xbb &c{RANK}"
+ incomplete: "&r{OLD_RANK} &e\xbb &r{RANK}"
+ranks:
+ # an empty string disables the header/footer
+ header: ''
+ footer: ''
+
+not-in-ladder: "&cSorry, but we could not find any rankups for the group(s) you are in."
\ No newline at end of file
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
new file mode 100644
index 0000000..9d46051
--- /dev/null
+++ b/src/main/resources/plugin.yml
@@ -0,0 +1,41 @@
+name: Rankup
+version: 3.0-alpha
+main: sh.okx.rankup.Rankup
+author: Okx
+depend: [Vault]
+softdepend: [PlaceholderAPI]
+api-version: 1.13
+
+commands:
+ rankup:
+ permission: rankup.rankup
+ description: Rankup.
+ rankup3:
+ permission: rankup3.info
+ description: View Rankup version and perform some administrative commands.
+ # support the old command
+ aliases: [pru]
+ ranks:
+ permission: rankup.ranks
+ description: List all the ranks.
+permissions:
+ rankup.*:
+ children:
+ rankup.info: true
+ rankup.rankup: true
+ rankup.checkversion: true
+ rankup.ranks: true
+ rankup.reload: true
+ rankup.ranks: true
+ rankup.info:
+ default: true
+ rankup.rankup:
+ default: true
+ rankup.checkversion:
+ default: op
+ rankup.ranks:
+ default: true
+ rankup.reload:
+ default: op
+ rankup.ranks:
+ default: true
\ No newline at end of file
diff --git a/src/main/resources/rankups.yml b/src/main/resources/rankups.yml
new file mode 100644
index 0000000..42be2cc
--- /dev/null
+++ b/src/main/resources/rankups.yml
@@ -0,0 +1,49 @@
+Aexample:
+ # the name of the rank in your permissions plugin
+ rank: 'A'
+ # the next rank a player can rank up to.
+ # this must be the name of the configuration section.
+ # for example, the name of this configuration section is "Aexample".
+ # this is not required.
+ next: 'Bexample'
+ # List of requirements to go to the next rank
+ # (ie, this example will charge 1000 money to rankup from A to B)
+ # money: money from the server economy
+ # xp-level: amount of experience levels
+ # playtime-hours: hours a player has played
+ # custom requirements can also be added by other plugins.
+ requirements:
+ money: 1000
+ # What requirements players need to match to /rankup.
+ # this is optional - if you don't use it, it defaults to "and"
+ # n.b. if there are no requirements players will always be able to /rankup.
+ # and: all requirements
+ # or: at least one requirement
+ # xor: only one requirement
+ # none: no requirements
+ operation: and
+ # the console will run these commands when a player ranks up
+ #commands:
+ # this will run when a player ranks up from A to B.
+ #- 'say {PLAYER} well done for ranking up from {OLD_RANK} to {RANK}!'
+Bexample:
+ rank: 'B'
+ next: 'Cexample'
+ requirements:
+ money: 2500
+Cexample:
+ rank: 'C'
+ next: 'Dexample'
+ requirements:
+ money: 5000
+ xp-level: 2
+ # you can have a custom messages too.
+ # you can use this to list the requirements needed.
+ rankup:
+ requirements-not-met: '&cYou need 5000 money and 2 levels of XP to rankup to D.'
+ ranks:
+ complete: "&7{OLD_RANK} &8\xbb &7{RANK} &e(5000 money, 2 XP levels)"
+ current: "&c{OLD_RANK} &e\xbb &c{RANK} &e(5000 money, 2 XP levels)"
+ incomplete: "&r{OLD_RANK} &e\xbb &r{RANK} &e(5000 money, 2 XP levels)"
+Dexample:
+ rank: 'D'
\ No newline at end of file
diff --git a/src/test/java/sh/okx/rankup/RankupTest.java b/src/test/java/sh/okx/rankup/RankupTest.java
new file mode 100644
index 0000000..8d88f18
--- /dev/null
+++ b/src/test/java/sh/okx/rankup/RankupTest.java
@@ -0,0 +1,9 @@
+package sh.okx.rankup;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class RankupTest {
+}
\ No newline at end of file