diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8afa843 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: Package CyberLevels + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: [blacksmith-4vcpu-ubuntu-2204] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK (Temurin 21) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew --no-daemon clean build shadowJar + + - name: Get version from Gradle + run: | + VERSION=$(./gradlew -q properties | awk -F': ' '/^version:/ {print $2; exit}') + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Upload CyberLevels artifact + uses: actions/upload-artifact@v4 + with: + name: CyberLevels-artifacts-${{ env.VERSION }} + if-no-files-found: error + path: build/libs/CyberLevels-${{ env.VERSION }}.jar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..624a755 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,243 @@ +name: Publish CyberLevels Release + +on: + workflow_run: + workflows: [ "Package CyberLevels" ] + types: [ completed ] + branches: [ "master" ] + +permissions: + actions: read + contents: write + pull-requests: read + +concurrency: + group: cyberlevels-release-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + release: + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event != 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Download build artifact + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + + gh run download "$RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir downloaded-artifacts + + JAR=$(find downloaded-artifacts -type f \ + -name 'CyberLevels-*.jar' \ + ! -name '*-sources.jar' \ + ! -name '*-javadoc.jar' \ + -print -quit) + + if [ -z "$JAR" ]; then + echo "Could not find final CyberLevels jar in downloaded artifacts." + find downloaded-artifacts -type f + exit 1 + fi + + VERSION=$(basename "$JAR" | sed -E 's/^CyberLevels-(.+)\.jar$/\1/') + mkdir -p release-assets + cp "$JAR" "release-assets/CyberLevels-$VERSION.jar" + + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=$VERSION" >> "$GITHUB_ENV" + echo "RELEASE_JAR=release-assets/CyberLevels-$VERSION.jar" >> "$GITHUB_ENV" + + - name: Build release notes + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + + OWNER="${GITHUB_REPOSITORY%/*}" + REPO="${GITHUB_REPOSITORY#*/}" + + git fetch --tags --force + + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release delete "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --cleanup-tag \ + --yes + elif git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + gh api \ + --method DELETE \ + "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" || true + fi + + BUMP_SUBJECT="chore: bump version to $VERSION" + BUMP_LOG=$(mktemp) + git log --format='%H%x09%s' "$HEAD_SHA" > "$BUMP_LOG" + BUMP_COMMIT=$(awk -F '\t' -v subject="$BUMP_SUBJECT" '$2 == subject { print $1; exit }' "$BUMP_LOG") + + if [ -n "$BUMP_COMMIT" ]; then + if BUMP_PARENT=$(git rev-parse "$BUMP_COMMIT^" 2>/dev/null); then + RANGE="$BUMP_PARENT..$HEAD_SHA" + CHANGELOG_BASE="$BUMP_PARENT" + else + RANGE="$HEAD_SHA" + CHANGELOG_BASE="$BUMP_COMMIT" + fi + else + RELEASE_TAGS=$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --exclude-drafts \ + --limit 100 \ + --json tagName \ + --jq ".[].tagName | select(. != \"$TAG\")") + + PREVIOUS_TAG=$(awk 'NF { print; exit }' <<< "$RELEASE_TAGS") + + if [ -z "$PREVIOUS_TAG" ]; then + echo "Could not find '$BUMP_SUBJECT' or a previous release tag." + echo "Refusing to generate release notes from the full repository history." + exit 1 + fi + + RANGE="$PREVIOUS_TAG..$HEAD_SHA" + CHANGELOG_BASE="$PREVIOUS_TAG" + fi + + FULL_CHANGELOG="https://github.com/$GITHUB_REPOSITORY/compare/$CHANGELOG_BASE...$TAG" + + QUERY=' + query($owner:String!, $repo:String!, $oid:GitObjectID!) { + repository(owner:$owner, name:$repo) { + object(oid:$oid) { + ... on Commit { + messageHeadline + abbreviatedOid + author { + name + user { + login + } + } + associatedPullRequests(first: 1) { + nodes { + number + title + author { + login + } + } + } + } + } + } + }' + + declare -A SEEN_PULL_REQUESTS + CONTRIBUTORS=$(mktemp) + PREVIOUS_CONTRIBUTORS=$(mktemp) + + if [ -n "${CHANGELOG_BASE:-}" ] && git rev-parse "$CHANGELOG_BASE" >/dev/null 2>&1; then + git log --format='%aN' "$CHANGELOG_BASE" \ + | sort -fu > "$PREVIOUS_CONTRIBUTORS" + fi + + { + echo "## What's Changed" + echo + } > release-notes.md + + HAS_CHANGES=false + while read -r SHA; do + [ -z "$SHA" ] && continue + HAS_CHANGES=true + + DATA=$(gh api graphql \ + -f query="$QUERY" \ + -f owner="$OWNER" \ + -f repo="$REPO" \ + -F oid="$SHA") + + PR_NUMBER=$(echo "$DATA" | jq -r '.data.repository.object.associatedPullRequests.nodes[0].number // empty') + PR_TITLE=$(echo "$DATA" | jq -r '.data.repository.object.associatedPullRequests.nodes[0].title // empty') + PR_AUTHOR=$(echo "$DATA" | jq -r '.data.repository.object.associatedPullRequests.nodes[0].author.login // empty') + + COMMIT_TITLE=$(echo "$DATA" | jq -r '.data.repository.object.messageHeadline') + COMMIT_AUTHOR_LOGIN=$(echo "$DATA" | jq -r '.data.repository.object.author.user.login // empty') + COMMIT_AUTHOR_NAME=$(echo "$DATA" | jq -r '.data.repository.object.author.name // "unknown"') + SHORT_SHA=$(echo "$DATA" | jq -r '.data.repository.object.abbreviatedOid') + + if [ -n "$PR_NUMBER" ]; then + if [ -z "${SEEN_PULL_REQUESTS[$PR_NUMBER]+x}" ]; then + echo "- $PR_TITLE by @$PR_AUTHOR in #$PR_NUMBER" >> release-notes.md + [ -n "$PR_AUTHOR" ] && echo "$PR_AUTHOR" >> "$CONTRIBUTORS" + SEEN_PULL_REQUESTS[$PR_NUMBER]=1 + fi + else + if [ -n "$COMMIT_AUTHOR_LOGIN" ]; then + echo "- $COMMIT_TITLE by @$COMMIT_AUTHOR_LOGIN ($SHORT_SHA)" >> release-notes.md + echo "$COMMIT_AUTHOR_LOGIN" >> "$CONTRIBUTORS" + else + echo "- $COMMIT_TITLE by $COMMIT_AUTHOR_NAME ($SHORT_SHA)" >> release-notes.md + fi + fi + done < <(git rev-list --reverse "$RANGE") + + if [ "$HAS_CHANGES" = false ]; then + echo "- No code changes detected for this release." >> release-notes.md + fi + + if [ -s "$CONTRIBUTORS" ]; then + NEW_CONTRIBUTORS=$(mktemp) + sort -u "$CONTRIBUTORS" \ + | grep -Fvxif "$PREVIOUS_CONTRIBUTORS" \ + > "$NEW_CONTRIBUTORS" || true + + if [ -s "$NEW_CONTRIBUTORS" ]; then + { + echo + echo "## New Contributors" + echo + sed 's/^/- @/' "$NEW_CONTRIBUTORS" + } >> release-notes.md + fi + + { + echo + echo "## Contributors" + echo + sort -u "$CONTRIBUTORS" | sed 's/^/- @/' + } >> release-notes.md + fi + + { + echo + echo "**Full Changelog**: $FULL_CHANGELOG" + } >> release-notes.md + + - name: Create prerelease + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + + gh release create "$TAG" "$RELEASE_JAR" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$VERSION" \ + --notes-file release-notes.md \ + --target "$HEAD_SHA" \ + --prerelease \ + --latest=false diff --git a/.gitignore b/.gitignore index 50f9169..6704e79 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ build/ *.tar.gz *.rar +!gradle/wrapper/gradle-wrapper.jar +!libraries/ +!libraries/*.jar + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* @@ -116,3 +120,4 @@ buildNumber.properties # Common working directory run/ /libs/ +/.claude/settings.local.json diff --git a/README.md b/README.md index f452305..6173065 100644 Binary files a/README.md and b/README.md differ diff --git a/build.gradle b/build.gradle deleted file mode 100644 index b3c341d..0000000 --- a/build.gradle +++ /dev/null @@ -1,113 +0,0 @@ -plugins { - id 'java' - id 'com.gradleup.shadow' version '8.3.6' -} - -group = 'com.bitaspire' -version = '1.0.4' - -repositories { - mavenLocal() - flatDir { - dirs 'libs' - } - - maven { - url = uri('https://hub.spigotmc.org/nexus/content/repositories/snapshots/') - } - - maven { - url = uri('https://oss.sonatype.org/content/groups/public/') - } - - maven { - url = uri('https://repo.extendedclip.com/content/repositories/placeholderapi/') - } - - maven { - url = uri('https://repo.maven.apache.org/maven2/') - } - - maven { - url = uri('https://jitpack.io') - } - - maven { - url = uri('https://croabeast.github.io/repo/') - } -} - -dependencies { - // Spigot API - compileOnly "org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT" - - // Core dependency - implementation 'com.github.Kihsomray:CyberCore:1.3.32' - - // Lombok - compileOnly "org.projectlombok:lombok:1.18.38" - annotationProcessor "org.projectlombok:lombok:1.18.38" - - // Other plugin APIs - compileOnly 'me.clip:placeholderapi:2.11.6' - compileOnly files('libs/RivalHarvesterHoesAPI.jar') - compileOnly files('libs/RivalPickaxesAPI.jar') - - // Database - compileOnly 'com.zaxxer:HikariCP:3.4.5' - compileOnly 'mysql:mysql-connector-java:8.0.21' - compileOnly 'org.xerial:sqlite-jdbc:3.36.0.3' - compileOnly 'org.postgresql:postgresql:42.7.7' - - // Misc utils - implementation 'org.bstats:bstats-bukkit:3.0.2' - implementation 'me.croabeast:YAML-API:1.1' - implementation 'me.croabeast:GlobalScheduler:1.0' - compileOnly 'ch.obermuhlner:big-math:2.3.2' - compileOnly 'org.apache.commons:commons-lang3:3.18.0' -} - -def targetJavaVersion = 8 -java { - def javaVersion = JavaVersion.toVersion(targetJavaVersion) - sourceCompatibility = javaVersion - targetCompatibility = javaVersion - if (JavaVersion.current() < javaVersion) { - toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) - } -} - -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' -} - -tasks.withType(Javadoc).configureEach { - options.encoding = 'UTF-8' -} - -processResources { - def props = [version: version] - inputs.properties props - filteringCharset 'UTF-8' - filesMatching('plugin.yml') { - expand props - } -} - -tasks.build.dependsOn(tasks.shadowJar) - -tasks { - shadowJar { - archiveClassifier.set('') - - exclude( - 'META-INF/**', 'org/apache/commons/**', 'org/intellij/**', - 'org/jetbrains/**', 'me/croabeast/file/plugin/YAMLPlugin.*' - ) - - relocate('org.bstats', 'com.bitaspire.libs.bstats') - relocate('me.croabeast.common', 'com.bitaspire.libs.file') - relocate('me.croabeast', 'com.bitaspire.libs') - relocate('net.zerotoil.dev', 'com.bitaspire.libs') - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8cbcd56 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,111 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.external.javadoc.StandardJavadocDocletOptions + +plugins { + id("java-library") + id("io.freefair.lombok") version "9.4.0" + id("com.gradleup.shadow") version "9.4.1" +} + +group = "com.bitaspire" +version = "1.2.3" + +repositories { + mavenLocal() + + flatDir { dirs("libraries") } + + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") + maven("https://oss.sonatype.org/content/groups/public/") + maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") + maven("https://repo.maven.apache.org/maven2/") + maven("https://jitpack.io") + maven("https://croabeast.github.io/repo/") +} + +dependencies { + // JetBrains + compileOnly("org.jetbrains:annotations:26.0.2") + annotationProcessor("org.jetbrains:annotations:26.0.2") + + // Lombok + compileOnly("org.projectlombok:lombok:1.18.44") + annotationProcessor("org.projectlombok:lombok:1.18.44") + + // Spigot API + compileOnly("org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT") + + implementation(files("libraries/CyberCore-2.0.0.jar")) + + compileOnly("me.clip:placeholderapi:2.11.6") + compileOnly(files("libraries/RivalHarvesterHoesAPI.jar")) + compileOnly(files("libraries/RivalPickaxesAPI.jar")) + compileOnly(files("libraries/AxBoosters-3.15.3.jar")) + compileOnly(files("libraries/AxHoes-1.2.0.jar")) + compileOnly(files("libraries/AxPickaxes-1.0.0.jar")) + compileOnly("net.kyori:adventure-key:4.17.0") + + compileOnly("com.zaxxer:HikariCP:7.0.2") + compileOnly("com.mysql:mysql-connector-j:9.5.0") + compileOnly("org.xerial:sqlite-jdbc:3.51.1.0") + compileOnly("org.postgresql:postgresql:42.7.8") + compileOnly("com.h2database:h2:2.3.232") + + implementation("me.croabeast.expr4j:core:1.0") + implementation("me.croabeast.expr4j:big-decimal:1.0") + implementation("me.croabeast.expr4j:double:1.0") + + compileOnly("ch.obermuhlner:big-math:2.3.2") + compileOnly("org.apache.commons:commons-lang3:3.18.0") +} + +tasks.withType().configureEach { + isFailOnError = false + + (options as StandardJavadocDocletOptions).apply { + addStringOption("Xdoclint:none", "-quiet") + encoding = "UTF-8" + charSet = "UTF-8" + docEncoding = "UTF-8" + + if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_1_9)) + addBooleanOption("html5", true) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + sourceCompatibility = "1.8" + targetCompatibility = "1.8" + options.compilerArgs.add("-Xlint:-options") + options.compilerArgs.add("-Xlint:-deprecation") +} + +tasks.processResources { + val props = mapOf("version" to version) + inputs.properties(props) + filteringCharset = "UTF-8" + filesMatching("plugin.yml") { + expand(props) + } +} + +tasks.named("build") { + dependsOn(tasks.named("shadowJar")) +} + +tasks.named("shadowJar") { + archiveClassifier.set("") + + relocate("me.croabeast.expr4j", "com.bitaspire.libs.expr4j") + + exclude( + "META-INF/**", + "org/apache/commons/**", + "org/intellij/**", + "org/jetbrains/**", + "**.json" + ) +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 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 index d4081da..b52fb7e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..b9bb139 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee..aa5f10b 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/libraries/AxBoosters-3.15.3.jar b/libraries/AxBoosters-3.15.3.jar new file mode 100644 index 0000000..1fd48c3 Binary files /dev/null and b/libraries/AxBoosters-3.15.3.jar differ diff --git a/libraries/AxHoes-1.2.0.jar b/libraries/AxHoes-1.2.0.jar new file mode 100644 index 0000000..09188c9 Binary files /dev/null and b/libraries/AxHoes-1.2.0.jar differ diff --git a/libraries/AxPickaxes-1.0.0.jar b/libraries/AxPickaxes-1.0.0.jar new file mode 100644 index 0000000..a494f7e Binary files /dev/null and b/libraries/AxPickaxes-1.0.0.jar differ diff --git a/libraries/CyberCore-2.0.0.jar b/libraries/CyberCore-2.0.0.jar new file mode 100644 index 0000000..2383b7b Binary files /dev/null and b/libraries/CyberCore-2.0.0.jar differ diff --git a/libraries/RivalHarvesterHoesAPI.jar b/libraries/RivalHarvesterHoesAPI.jar new file mode 100644 index 0000000..76cb1f6 Binary files /dev/null and b/libraries/RivalHarvesterHoesAPI.jar differ diff --git a/libraries/RivalPickaxesAPI.jar b/libraries/RivalPickaxesAPI.jar new file mode 100644 index 0000000..9aae0eb Binary files /dev/null and b/libraries/RivalPickaxesAPI.jar differ diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 10e2f36..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'CyberLevels' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ea9589f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "CyberLevels" diff --git a/src/main/java/com/bitaspire/cyberlevels/BaseSystem.java b/src/main/java/com/bitaspire/cyberlevels/BaseSystem.java index 985edca..e49b366 100644 --- a/src/main/java/com/bitaspire/cyberlevels/BaseSystem.java +++ b/src/main/java/com/bitaspire/cyberlevels/BaseSystem.java @@ -1,7 +1,7 @@ package com.bitaspire.cyberlevels; +import com.bitaspire.cyberlevels.event.ExpChangeEvent; import com.bitaspire.cyberlevels.user.UserManager; -import com.bitaspire.libs.formula.expression.ExpressionBuilder; import com.bitaspire.cyberlevels.cache.Cache; import com.bitaspire.cyberlevels.cache.Lang; import com.bitaspire.cyberlevels.level.*; @@ -9,7 +9,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; -import me.croabeast.beanslib.Beans; +import me.croabeast.expr4j.expression.Builder; import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -20,10 +20,12 @@ import java.math.RoundingMode; import java.text.DecimalFormat; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Function; import java.util.stream.Collectors; +import net.zerotoil.dev.cyberlevels.api.events.XPChangeEvent; @Getter abstract class BaseSystem implements LevelSystem { @@ -34,6 +36,7 @@ abstract class BaseSystem implements LevelSystem { private final long startLevel, maxLevel; private final int startExp; + private final Operator operator; private final Formula formula; private final Map> formulas = new ConcurrentHashMap<>(); @@ -55,6 +58,7 @@ abstract class BaseSystem implements LevelSystem { startLevel = cache.levels().getStartLevel(); maxLevel = cache.levels().getMaxLevel(); + operator = createOperator(); formula = createFormula(cache.levels().getFormula()); cache.levels().getCustomFormulas().forEach((k, v) -> formulas.put(k, createFormula(v))); @@ -62,6 +66,7 @@ abstract class BaseSystem implements LevelSystem { if (cache.config().isRoundingEnabled()) formatter = new DecimalFormatter<>(this); } + abstract Operator createOperator(); abstract Formula createFormula(String formula); @Override @@ -115,7 +120,7 @@ public String formatNumber(Number value) { @NotNull public List getRewards(long level) { - return rewardMap.getOrDefault(level, new ArrayList<>()); + return rewardMap.getOrDefault(level, Collections.emptyList()); } @NotNull @@ -155,7 +160,7 @@ public String replacePlaceholders(String string, UUID uuid, boolean safeForFormu string = StringUtils.replaceEach(string, k, v); } - return Beans.formatPlaceholders(data.isOnline() ? data.getPlayer() : null, string); + return main.library().replace(data.isOnline() ? data.getPlayer() : null, string); } @NotNull @@ -214,9 +219,7 @@ public Map getAntiAbuses() { @NotNull LevelUser createUser(UUID uuid) { Player player = Bukkit.getPlayer(uuid); - return player == null ? - new OfflineUser<>(this, Bukkit.getOfflinePlayer(uuid)) : - new OnlineUser<>(this, player); + return player == null ? createOffline(uuid) : new OnlineUser<>(this, player); } @NotNull @@ -227,13 +230,30 @@ LevelUser createOffline(UUID uuid) { @NotNull LevelUser createUser(LevelUser user) { LevelUser newUser = createUser(user.getUuid()); + long highestRewarded = user.getLevel(); + try { + highestRewarded = (long) user.getClass().getMethod("getHighestRewardedLevel").invoke(user); + } catch (Exception ignored) {} - newUser.setLevel(user.getLevel(), false); - newUser.setExp(user.getExp() + "", true, false, false); - + applyStoredState(newUser, user.getLevel(), String.valueOf(user.getExp()), highestRewarded); return newUser; } + @SuppressWarnings({"rawtypes", "unchecked"}) + void applyStoredState(LevelUser user, long level, String exp, long highestRewardedLevel) { + if (user instanceof BaseUser) { + ((BaseUser) user).applyStoredState(level, exp, highestRewardedLevel); + return; + } + + user.setLevel(level, false); + user.setExp(exp, false, false, false); + + try { + user.getClass().getMethod("setHighestRewardedLevel", long.class).invoke(user, highestRewardedLevel); + } catch (Exception ignored) {} + } + static class DecimalFormatter { final DecimalFormat decimalFormat; @@ -266,7 +286,7 @@ abstract class BaseFormula implements Formula { @Getter private final String asString; - abstract ExpressionBuilder builder(); + abstract Builder builder(); @NotNull public T evaluate(UUID uuid) { @@ -277,6 +297,7 @@ public T evaluate(UUID uuid) { try { return builder().build(parsed).evaluate(); } catch (Throwable t) { + t.printStackTrace(); return operator.fromDouble(0.0); } } @@ -287,7 +308,8 @@ abstract class BaseLeaderboard implements Leaderboard { private final UserManager userManager; - private volatile boolean updating = false; + private final AtomicBoolean updating = new AtomicBoolean(false); + private final AtomicBoolean dirty = new AtomicBoolean(false); protected final List> topTenPlayers = new CopyOnWriteArrayList<>(); BaseLeaderboard(UserManager manager) { @@ -301,33 +323,59 @@ public List> getTopTenPlayers() { @Override public void update() { + dirty.set(true); + if (updating.compareAndSet(false, true)) + runUpdatePass(); + } + + private void runUpdatePass() { + dirty.set(false); List> users = userManager.getUsersList(); - updating = true; - Bukkit.getScheduler().runTaskAsynchronously(main, () -> { + main.scheduler().runTaskAsynchronously(() -> { List> list = new ArrayList<>(); for (LevelUser user : users) list.add(toEntry(user)); list.sort(Comparator.naturalOrder()); - List> top10 = list.subList(0, Math.min(10, list.size())); + int max = cache.config().getLeaderboardMaxPositions(); + List> top = new ArrayList<>(list.subList(0, Math.min(max, list.size()))); - Bukkit.getScheduler().runTask(main, () -> { - topTenPlayers.clear(); - topTenPlayers.addAll(top10); - updating = false; - }); + main.scheduler().runTask(() -> finishUpdatePass(top)); }); } + private void finishUpdatePass(List> top) { + topTenPlayers.clear(); + topTenPlayers.addAll(top); + + if (dirty.get()) { + runUpdatePass(); + return; + } + + updating.set(false); + if (dirty.get() && updating.compareAndSet(false, true)) + runUpdatePass(); + } + @Override - public LevelUser getTopPlayer(int position) { - return updating || position < 1 || position > 10 ? null : userManager.getUser(topTenPlayers.get(position - 1).getUuid()); + public boolean isUpdating() { + return updating.get(); } @Override - public int checkPosition(Player player) { - UUID uuid = player.getUniqueId(); + public LevelUser getTopPlayer(int position) { + int max = cache.config().getLeaderboardMaxPositions(); + if (updating.get() || position < 1 || position > max) return null; + int index = position - 1; + List> snapshot = new ArrayList<>(topTenPlayers); + if (index >= snapshot.size()) return null; + + return userManager.getUser(snapshot.get(index).getUuid()); + } + + int check(UUID uuid) { for (int i = 0; i < topTenPlayers.size(); i++) if (uuid.equals(topTenPlayers.get(i).getUuid())) return i + 1; @@ -335,9 +383,14 @@ public int checkPosition(Player player) { return -1; } + @Override + public int checkPosition(Player player) { + return check(player.getUniqueId()); + } + @Override public int checkPosition(LevelUser user) { - return checkPosition(user.getPlayer()); + return check(user.getUuid()); } abstract Entry toEntry(LevelUser user); @@ -362,10 +415,10 @@ abstract class Entry implements Comparable> { } void updateLeaderboard() { - if (!main.isEnabled() || leaderboard == null) return; + if (!main.isEnabled() || leaderboard == null || + !cache.config().isLeaderboardEnabled()) return; - if (cache.config().leaderboardInstantUpdate() && !leaderboard.isUpdating()) - leaderboard.update(); + if (!leaderboard.isUpdating()) leaderboard.update(); } abstract class BaseUser implements LevelUser { @@ -389,6 +442,28 @@ public void setHighestRewardedLevel(long value) { this.highestRewardedLevel = Math.max(0L, value); } + void applyStoredState(long level, String expValue, long highestRewardedLevel) { + long min = getStartLevel(); + long max = getMaxLevel(); + + this.level = Math.max(Math.min(level, max), min); + + T parsed; + try { + parsed = operator.abs(operator.valueOf(expValue)); + } catch (Exception ignored) { + parsed = operator.fromDouble(getStartExp()); + } + + exp = parsed; + if (operator.compare(exp, operator.zero()) < 0) + exp = operator.zero(); + + lastAmount = operator.zero(); + lastTime = 0L; + this.highestRewardedLevel = Math.max(0L, highestRewardedLevel); + } + BaseUser(BaseSystem system, UUID uuid) { this.uuid = uuid; exp = (this.operator = (this.system = system).getOperator()).fromDouble(getStartExp()); @@ -398,6 +473,8 @@ public void setHighestRewardedLevel(long value) { } void sendLevelReward(long level) { + if (!isOnline()) return; + if (!cache.config().preventDuplicateRewards()) { getRewards(level).forEach(r -> r.giveAll(getPlayer())); return; @@ -427,12 +504,12 @@ void updateLevel(long newLevel, boolean sendMessage, boolean giveRewards) { if (operator.compare(exp, operator.zero()) < 0) exp = operator.zero(); - if (sendMessage) { + if (sendMessage && isOnline()) { long diff = level - oldLevel; if (diff > 0) { - cache.lang().sendMessage(getPlayer(), Lang::getGainedLevels, "gainedLevels", diff); + cache.lang().sendMessage(getPlayer(), Lang::getGainedLevels, new String[] {"gainedLevels", "level"}, diff, level); } else if (diff < 0) { - cache.lang().sendMessage(getPlayer(), Lang::getLostLevels, "lostLevels", Math.abs(diff)); + cache.lang().sendMessage(getPlayer(), Lang::getLostLevels, new String[] {"lostLevels", "level"}, Math.abs(diff), level); } } @@ -460,28 +537,39 @@ public void removeLevel(long amount) { private void changeExp(T amount, T difference, boolean sendMessage, boolean doMultiplier, boolean checkLeaderboard) { if (operator.compare(amount, operator.zero()) == 0) return; + long startingLevel = level; + Player player = sendMessage && isOnline() ? getPlayer() : null; + boolean shouldSendMessage = sendMessage && player != null; if (operator.compare(amount, operator.zero()) > 0 && level >= getMaxLevel()) return; - if (doMultiplier && operator.compare(amount, operator.zero()) > 0 && - hasParentPerm("CyberLevels.player.multiplier.", false)) - amount = operator.multiply(amount, operator.fromDouble(getMultiplier())); + if (doMultiplier && operator.compare(amount, operator.zero()) > 0) { + double multiplier = getMultiplier(); + if (multiplier != 1D) { + amount = operator.multiply(amount, operator.fromDouble(multiplier)); + } + } + + if (operator.compare(amount, operator.zero()) > 0 && isOnline()) { + amount = fireExpEvents(amount); + if (operator.compare(amount, operator.zero()) == 0) return; + } final T totalAmount = amount; - long levelsChanged = 0; if (operator.compare(amount, operator.zero()) > 0) { - while (operator.compare(operator.add(exp, amount), rawRequiredExp()) >= 0) { + T requiredExp = rawRequiredExp(); + while (operator.compare(operator.add(exp, amount), requiredExp) >= 0) { if (level == getMaxLevel()) { exp = operator.zero(); return; } - amount = operator.add(operator.subtract(amount, rawRequiredExp()), exp); + amount = operator.add(operator.subtract(amount, requiredExp), exp); exp = operator.zero(); level++; - levelsChanged++; sendLevelReward(level); + requiredExp = rawRequiredExp(); } exp = operator.add(exp, amount); @@ -492,7 +580,6 @@ private void changeExp(T amount, T difference, boolean sendMessage, boolean doMu while (operator.compare(amount, exp) > 0 && level > getStartLevel()) { amount = operator.subtract(amount, exp); level--; - levelsChanged--; exp = rawRequiredExp(); } exp = operator.subtract(exp, amount); @@ -503,44 +590,114 @@ private void changeExp(T amount, T difference, boolean sendMessage, boolean doMu } } - T displayTotal = (cache.config().stackComboExp() && System.currentTimeMillis() - lastTime <= 650) + long now = System.currentTimeMillis(); + T displayTotal = (cache.config().stackComboExp() && now - lastTime <= 650) ? operator.add(amount, lastAmount) : amount; - if (sendMessage) { + if (shouldSendMessage) { T diff = operator.subtract(Objects.equals(displayTotal, operator.zero()) ? operator.zero() : displayTotal, difference); if (operator.compare(totalAmount, operator.zero()) > 0) { cache.lang().sendMessage( - getPlayer(), Lang::getGainedExp, new String[] {"gainedEXP", "totalGainedEXP"}, + player, Lang::getGainedExp, new String[] {"gainedEXP", "totalGainedEXP"}, system.roundString(diff), system.roundString(totalAmount) ); } else if (operator.compare(totalAmount, operator.zero()) < 0) { cache.lang().sendMessage( - getPlayer(), Lang::getLostExp, new String[] {"lostEXP", "totalLostEXP"}, + player, Lang::getLostExp, new String[] {"lostEXP", "totalLostEXP"}, system.roundString(operator.abs(diff)), system.roundString(operator.abs(totalAmount)) ); } - - if (levelsChanged > 0) { - cache.lang().sendMessage(getPlayer(), Lang::getGainedLevels, "gainedLevels", levelsChanged); - } else if (levelsChanged < 0) { - cache.lang().sendMessage(getPlayer(), Lang::getLostLevels, "lostLevels", Math.abs(levelsChanged)); - } } lastAmount = displayTotal; - lastTime = System.currentTimeMillis(); + lastTime = now; level = Math.max(getStartLevel(), Math.min(level, getMaxLevel())); if (operator.compare(exp, operator.zero()) < 0) exp = operator.zero(); + if (shouldSendMessage) { + long levelDifference = level - startingLevel; + if (levelDifference > 0) { + cache.lang().sendMessage(player, Lang::getGainedLevels, new String[] {"gainedLevels", "level"}, levelDifference, level); + } else if (levelDifference < 0) { + cache.lang().sendMessage(player, Lang::getLostLevels, new String[] {"lostLevels", "level"}, Math.abs(levelDifference), level); + } + } + if (checkLeaderboard) system.updateLeaderboard(); } + private T fireExpEvents(T amount) { + double oldExp = exp.doubleValue(); + long oldLevel = level; + + XPChangeEvent legacyEvent = new XPChangeEvent(getPlayer(), oldExp, amount.doubleValue()); + Bukkit.getPluginManager().callEvent(legacyEvent); + amount = operator.max(operator.fromDouble(legacyEvent.getAmount()), operator.zero()); + if (operator.compare(amount, operator.zero()) == 0) return amount; + + ExpPreview preview = previewPositiveExpChange(oldExp, oldLevel, amount.doubleValue()); + ExpChangeEvent event = new ExpChangeEvent( + this, + oldExp, + oldLevel, + preview.exp, + preview.level, + amount.doubleValue() + ); + event.call(); + return operator.max(operator.fromDouble(event.getExpAmount()), operator.zero()); + } + + private ExpPreview previewPositiveExpChange(double oldExp, long oldLevel, double amount) { + double previewExp = Math.max(0D, oldExp); + long previewLevel = Math.max(getStartLevel(), Math.min(oldLevel, getMaxLevel())); + double remaining = Math.max(0D, amount); + + while (remaining > 0D && previewLevel < getMaxLevel()) { + double required = system.getRequiredExp(previewLevel, uuid).doubleValue(); + if (required <= 0D) { + previewLevel++; + previewExp = 0D; + continue; + } + + if (previewExp + remaining < required) { + previewExp += remaining; + remaining = 0D; + break; + } + + remaining = (previewExp + remaining) - required; + previewExp = 0D; + previewLevel++; + } + + if (previewLevel >= getMaxLevel()) { + previewLevel = getMaxLevel(); + previewExp = 0D; + } + + return new ExpPreview(previewExp, previewLevel); + } + + @RequiredArgsConstructor + private final class ExpPreview { + + final double exp; + final long level; + } + public void addExp(T amount, boolean doMultiplier) { changeExp(amount, operator.zero(), true, doMultiplier, true); } + @Override + public void addExp(double amount, boolean doMultiplier) { + addExp(operator.fromDouble(amount), doMultiplier); + } + @Override public void addExp(String amount, boolean multiply) { addExp(operator.valueOf(amount), multiply); @@ -552,7 +709,7 @@ public void setExp(T amount, boolean checkLevel, boolean sendMessage, boolean ch if (checkLevel) { T oldExp = this.exp; exp = operator.zero(); - changeExp(amount, oldExp, sendMessage, false, checkLeaderboard); + changeExp(amount, oldExp, sendMessage, false, false); } else this.exp = amount; @@ -564,12 +721,22 @@ public void setExp(String amount, boolean checkLevel, boolean sendMessage, boole setExp(operator.valueOf(amount), checkLevel, sendMessage, checkLeaderboard); } + @Override + public void setExp(double amount, boolean checkLevel, boolean sendMessage, boolean checkLeaderboard) { + setExp(operator.fromDouble(amount), checkLevel, sendMessage, checkLeaderboard); + } + public void removeExp(T amount) { T positive = operator.max(amount, operator.zero()); T negative = operator.negate(positive); changeExp(negative, operator.zero(), true, false, true); } + @Override + public void removeExp(double amount) { + removeExp(operator.fromDouble(amount)); + } + @Override public void removeExp(String amount) { removeExp(operator.valueOf(amount)); @@ -606,6 +773,7 @@ public String getProgressBar() { @Override public boolean hasParentPerm(String permission, boolean checkOp) { + if (!isOnline()) return false; if (checkOp && getPlayer().isOp()) return true; for (PermissionAttachmentInfo node : getPlayer().getEffectivePermissions()) { @@ -619,8 +787,9 @@ public boolean hasParentPerm(String permission, boolean checkOp) { @Override public double getMultiplier() { - double multiplier = 0; + if (!isOnline()) return 1; + double multiplier = 0; for (PermissionAttachmentInfo perm : getPlayer().getEffectivePermissions()) { if (!perm.getValue()) continue; @@ -634,7 +803,12 @@ public double getMultiplier() { } catch (Exception ignored) {} } - return multiplier == 0 ? 1 : multiplier; + double base = multiplier == 0 ? 1 : multiplier; + + if (system.main.hookManager != null) + base *= system.main.hookManager.externalMultiplier(getPlayer()); + + return base; } @Override diff --git a/src/main/java/com/bitaspire/cyberlevels/BigDecimalLevelSystem.java b/src/main/java/com/bitaspire/cyberlevels/BigDecimalSystem.java similarity index 86% rename from src/main/java/com/bitaspire/cyberlevels/BigDecimalLevelSystem.java rename to src/main/java/com/bitaspire/cyberlevels/BigDecimalSystem.java index 26ff48a..b1df343 100644 --- a/src/main/java/com/bitaspire/cyberlevels/BigDecimalLevelSystem.java +++ b/src/main/java/com/bitaspire/cyberlevels/BigDecimalSystem.java @@ -1,27 +1,28 @@ package com.bitaspire.cyberlevels; import com.bitaspire.cyberlevels.user.UserManager; -import com.bitaspire.libs.formula.BigDecimalExpressionBuilder; import com.bitaspire.cyberlevels.level.Formula; import com.bitaspire.cyberlevels.level.Operator; import com.bitaspire.cyberlevels.user.LevelUser; -import com.bitaspire.libs.formula.expression.ExpressionBuilder; import lombok.Getter; +import me.croabeast.expr4j.BigDecimalBuilder; +import me.croabeast.expr4j.expression.Builder; import org.jetbrains.annotations.NotNull; import java.math.BigDecimal; import java.math.RoundingMode; @Getter -final class BigDecimalLevelSystem extends BaseSystem { +final class BigDecimalSystem extends BaseSystem { - private final Operator operator; - - BigDecimalLevelSystem(CyberLevels main) { + BigDecimalSystem(CyberLevels main) { super(main); setLeaderboardFunction(BigDecimalLeaderboard::new); + } - operator = new Operator() { + @Override + Operator createOperator() { + return new Operator() { @Override public BigDecimal zero() { return BigDecimal.ZERO; @@ -119,10 +120,10 @@ public int compareTo(@NotNull Entry other) { @Override Formula createFormula(String string) { - return new BaseFormula(operator, string) { + return new BaseFormula(getOperator(), string) { @NotNull - ExpressionBuilder builder() { - return new BigDecimalExpressionBuilder(); + Builder builder() { + return new BigDecimalBuilder(); } }; } diff --git a/src/main/java/com/bitaspire/cyberlevels/CyberLevels.java b/src/main/java/com/bitaspire/cyberlevels/CyberLevels.java index c7ae30b..2b80a13 100644 --- a/src/main/java/com/bitaspire/cyberlevels/CyberLevels.java +++ b/src/main/java/com/bitaspire/cyberlevels/CyberLevels.java @@ -1,5 +1,9 @@ package com.bitaspire.cyberlevels; +import com.bitaspire.libs.common.CollectionBuilder; +import com.bitaspire.libs.vnc.VNC; +import com.bitaspire.cybercore.CoreSettings; +import com.bitaspire.cybercore.CyberCore; import com.bitaspire.cyberlevels.cache.Cache; import com.bitaspire.cyberlevels.command.CLVCommand; import com.bitaspire.cyberlevels.command.CLVTabComplete; @@ -8,17 +12,34 @@ import com.bitaspire.cyberlevels.listener.Listeners; import com.bitaspire.cyberlevels.user.Database; import com.bitaspire.cyberlevels.user.UserManager; +import com.bitaspire.cyberlevels.utility.SpigotUpdateChecker; +import com.bitaspire.libs.scheduler.GlobalScheduler; +import com.bitaspire.libs.takion.TakionLib; +import com.bitaspire.libs.takion.message.MessageSender; import lombok.AccessLevel; import lombok.Getter; import lombok.experimental.Accessors; -import me.croabeast.beanslib.utility.LibUtils; -import me.croabeast.scheduler.GlobalScheduler; -import net.zerotoil.dev.cybercore.CoreSettings; -import net.zerotoil.dev.cybercore.CyberCore; +import net.zerotoil.dev.cyberlevels.api.events.XPChangeEvent; import org.bukkit.Bukkit; import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; - +import java.util.List; +import org.jetbrains.annotations.Nullable; + +/** + * Main plugin entry point for CyberLevels. + * + *

This class owns the full runtime lifecycle of the plugin: dependency bootstrap, command + * registration, cache loading, user/database startup, hook registration, leaderboard refreshes, + * and final shutdown. External plugins commonly reach CyberLevels services through the generated + * getters exposed here, such as {@link #cache()}, {@link #levelSystem()}, {@link #userManager()}, + * and {@link #library()}. + * + *

The runtime can also be rebuilt in-place through {@link #reloadPlugin()}, which safely tears + * down active listeners, hooks, scheduled tasks, and persistence components before creating fresh + * instances from the current configuration files. + */ @Accessors(fluent = true) @Getter public final class CyberLevels extends JavaPlugin { @@ -44,16 +65,44 @@ public final class CyberLevels extends JavaPlugin { @Getter(AccessLevel.NONE) HookManager hookManager; + /** + * Cached Spigot update notice that can be delivered to operators after the asynchronous + * version check completes. + * + *

The notice only stores normalized state and version strings. The final chat lines are + * still resolved from {@code lang.yml} at send time so administrators can localize or restyle + * the message without changing code. + */ + @Getter(AccessLevel.NONE) + private volatile SpigotOpUpdateNotice spigotOpUpdateNotice = SpigotOpUpdateNotice.none(); + + /** + * Boots the plugin and creates the first live runtime. + * + *

This method loads legacy dependencies when needed, initializes CyberCore and Takion, + * registers the base command executor/tab completer, and then delegates the actual runtime + * construction to {@link #reloadPlugin()}. Calling the shared reload path keeps startup and + * manual reload behaviour aligned. + */ @Override public void onEnable() { - if (!CyberCore.restrictVersions(8, 22, "CLV", getDescription().getVersion())) - return; + if (serverVersion() < 16) { + DependencyLoader loader = DependencyLoader.BUKKIT_LOADER; + loader.load("ch.obermuhlner", "big-math", "2.3.2", true); + loader.load("org.slf4j", "slf4j-api", "1.7.36", true); + loader.load("com.zaxxer", "HikariCP", "4.0.3", true); + loader.load("com.mysql", "mysql-connector-j", "8.0.33", true); + loader.load("org.xerial", "sqlite-jdbc", "3.51.1.0", true); + loader.load("org.postgresql", "postgresql", "42.7.8", true); + loader.load("com.h2database", "h2", "2.3.232", true); + loader.load("org.apache.commons", "commons-lang3", "3.18.0", true); + } instance = this; scheduler = GlobalScheduler.getScheduler(this); core = new CyberCore(this); - CoreSettings settings = core.coreSettings(); + CoreSettings settings = core.getSettings(); settings.setBootColor('d'); settings.setBootLogo( "&d╭━━━╮&7╱╱╱&d╭╮&7╱╱╱╱╱╱&d╭╮&7╱╱╱╱╱╱╱╱╱╱╱&d╭╮", @@ -71,28 +120,33 @@ public void onEnable() { PluginCommand command = this.getCommand("clv"); if (command != null) { command.setExecutor(new CLVCommand(this)); - command.setTabCompleter(new CLVTabComplete()); + command.setTabCompleter(new CLVTabComplete(this)); } reloadPlugin(); core.loadFinish(); } + /** + * Rebuilds the active plugin runtime from disk and from the current server state. + * + *

The previous runtime is stopped first through an internal shutdown routine so the reload + * can happen without duplicating listeners, hooks, database connections, or auto-save tasks. + * After that, this method recreates caches, picks the numeric engine, reloads users, registers + * event sources and anti-abuse modules, refreshes the leaderboard, and optionally schedules the + * Spigot update check. + */ + @SuppressWarnings("deprecation") public void reloadPlugin() { - if (cache != null) { - cache.antiAbuse().unregister(); - cache.earnExp().unregister(); - } - - if (hookManager != null) hookManager.unregister(); + shutdownRuntime(); (listeners = new Listeners(this)).register(); cache = new Cache(this); long start = System.currentTimeMillis(); BaseSystem system = !cache.config().useBigDecimalSystem() ? - new DoubleLevelSystem(this) : - new BigDecimalLevelSystem(this); + new DoubleSystem(this) : + new BigDecimalSystem(this); logger("&dChecking level system type..."); levelSystem = system; @@ -108,6 +162,7 @@ public void reloadPlugin() { manager.checkMigration(); database = (userManager = manager).getDatabase(); + logger(""); manager.loadOfflinePlayers(); userManager.loadOnlinePlayers(); @@ -119,38 +174,268 @@ public void reloadPlugin() { (hookManager = new HookManager(this)).register(); userManager.startAutoSave(); + if (userManager instanceof UserManagerImpl) + ((UserManagerImpl) userManager).startDatabaseSync(); levelSystem.getLeaderboard().update(); + + if (cache.config().isSpigotUpdateCheckEnabled()) + SpigotUpdateChecker.checkAsync(this); + + scheduler.runTaskLater(() -> { + List list = CollectionBuilder + .of(XPChangeEvent.getHandlerList().getRegisteredListeners()) + .map(l -> l.getPlugin().getName()).toList(); + if (!list.isEmpty()) + library().getLogger().log( + "Detected plugins still listening to deprecated XPChangeEvent: " + String.join(", ", list), + "Ask those plugins to migrate to com.bitaspire.cyberlevels.event.ExpChangeEvent." + ); + }, 1L); } - @Override - public void onDisable() { - cache.antiAbuse().unregister(); - cache.earnExp().unregister(); + private void shutdownRuntime() { + if (userManager != null) { + userManager.cancelAutoSave(); - hookManager.unregister(); + if (userManager instanceof UserManagerImpl) { + ((UserManagerImpl) userManager).saveOnlinePlayersSync(); + } else { + userManager.saveOnlinePlayers(true); + } + } - userManager.saveOnlinePlayers(true); - userManager.cancelAutoSave(); + if (cache != null) { + cache.antiAbuse().unregister(); + cache.earnExp().unregister(); + } + + if (hookManager != null) hookManager.unregister(); - if (database != null) database.disconnect(); + if (database != null) { + if (database instanceof DatabaseFactory.DatabaseImpl) { + ((DatabaseFactory.DatabaseImpl) database).disconnectSync(); + } else { + database.disconnect(); + } + database = null; + if (isEnabled()) logger(""); + } + + spigotOpUpdateNotice = SpigotOpUpdateNotice.none(); + + if (listeners != null) { + listeners.unregister(); + listeners = null; + } + + hookManager = null; + userManager = null; + levelSystem = null; + cache = null; + } - listeners.unregister(); + /** + * Shuts down the active runtime when Bukkit disables the plugin. + * + *

This flushes user data, stops auto-save and hooks, unregisters listeners, disconnects the + * database, and clears transient references so the JVM can release the old runtime cleanly. + */ + @Override + public void onDisable() { + shutdownRuntime(); } + /** + * Returns the plugin authors exactly as declared in the plugin description. + * + *

The backing Bukkit descriptor stores authors as a list. This helper flattens that list to + * the human-readable comma-separated format used in startup logs and the {@code /clv about} + * output. + * + * @return author list formatted for display + */ public String getAuthors() { - return this.getDescription().getAuthors().toString().replace("[", "").replace("]", ""); + return this.getDescription().getAuthors().toString().replaceAll("[\\[\\]]", ""); } + /** + * Returns the detected server version as a numeric major/minor value. + * + *

This is primarily used by internal compatibility branches, for example when deciding which + * Bukkit data APIs are available for crops, block states, or dependency bootstrapping. + * + * @return parsed server version such as {@code 20.4} + */ public double serverVersion() { - return LibUtils.getMainVersion(); + return VNC.SERVER_VERSION; + } + + /** + * Exposes the shared Takion library instance initialized through CyberCore. + * + *

Consumers can use this to access the loaded message sender, logger, and other library + * services that CyberLevels already wires and configures during startup. + * + * @return active Takion library facade + */ + public TakionLib library() { + return core.getLibrary(); } + /** + * Creates a preconfigured message sender targeting a specific player. + * + *

The returned sender already has the player bound both as the output target and as the + * placeholder parser context, which makes it suitable for one-off plugin messages without + * having to repeat the same setup each time. + * + * @param player player that should receive and parse the message + * @return configured sender instance + */ + public MessageSender createSender(Player player) { + return library().getLoadedSender().setTargets(player).setParser(player); + } + + /** + * Writes one or more lines to the plugin logger through Takion's formatting pipeline. + * + *

This helper is used throughout the plugin to keep console formatting consistent with the + * rest of the CyberCore ecosystem. + * + * @param message console lines to print in order + */ public void logger(String... message) { - core.logger(message); + library().getLogger().log(message); } + /** + * Checks whether Bukkit currently exposes a plugin instance under the given name. + * + *

This is used as a lightweight presence check before optional integrations are created. The + * method only verifies that the plugin can be resolved from the plugin manager; it does not + * perform any extra capability probing beyond that lookup. + * + * @param plugin plugin name as registered with Bukkit + * @return {@code true} when a plugin with that name is present, otherwise {@code false} + */ public boolean isEnabled(String plugin) { return Bukkit.getPluginManager().getPlugin(plugin) != null; } + + /** + * Replaces the cached operator-facing Spigot update notice. + * + *

Passing {@code null} clears the notice and falls back to {@link SpigotOpUpdateNotice#none()} + * so callers do not need to perform null checks before updating the cache. + * + * @param notice pending notice from the Spigot version check, or {@code null} to clear it + */ + public void setSpigotOpUpdateNotice(SpigotOpUpdateNotice notice) { + spigotOpUpdateNotice = notice != null ? notice : SpigotOpUpdateNotice.none(); + } + + /** + * Returns the cached Spigot update notice prepared for operators. + * + *

The returned object is immutable and can safely be shared between the asynchronous update + * checker and the synchronous chat delivery path. + * + * @return current cached notice, never {@code null} + */ + public SpigotOpUpdateNotice getSpigotOpUpdateNotice() { + return spigotOpUpdateNotice; + } + + /** + * Immutable snapshot describing the relationship between the local JAR version and the version + * currently listed on Spigot. + * + *

This type intentionally carries only normalized metadata. User-facing formatting remains in + * {@code lang.yml}, which keeps localization and styling outside of the code path. + */ + public static final class SpigotOpUpdateNotice { + + /** + * Notice kind used when no operator message should be shown. + */ + public static final byte KIND_NONE = 0; + /** + * Notice kind used when Spigot lists a newer public version than the local JAR. + */ + public static final byte KIND_NEWER = 1; + /** + * Notice kind used when the local JAR is newer than the currently listed Spigot version. + */ + public static final byte KIND_EARLY = 2; + + private final byte kind; + private final @Nullable String remoteVersion; + private final @Nullable String localVersion; + + private SpigotOpUpdateNotice(byte kind, @Nullable String remoteVersion, @Nullable String localVersion) { + this.kind = kind; + this.remoteVersion = remoteVersion; + this.localVersion = localVersion; + } + + /** + * Creates an empty notice representing the absence of update information. + * + * @return immutable notice with {@link #KIND_NONE} + */ + public static SpigotOpUpdateNotice none() { + return new SpigotOpUpdateNotice(KIND_NONE, null, null); + } + + /** + * Creates a notice indicating that Spigot exposes a newer version than the one running on + * the server. + * + * @param remoteVersion version reported by Spigot + * @param localVersion version currently running on the server + * @return immutable notice with {@link #KIND_NEWER} + */ + public static SpigotOpUpdateNotice newer(String remoteVersion, String localVersion) { + return new SpigotOpUpdateNotice(KIND_NEWER, remoteVersion, localVersion); + } + + /** + * Creates a notice indicating that the server is running an early-access build newer than + * the Spigot listing. + * + * @param localVersion version currently running on the server + * @return immutable notice with {@link #KIND_EARLY} + */ + public static SpigotOpUpdateNotice earlyAccess(String localVersion) { + return new SpigotOpUpdateNotice(KIND_EARLY, null, localVersion); + } + + /** + * Returns the notice kind constant. + * + * @return one of {@link #KIND_NONE}, {@link #KIND_NEWER}, or {@link #KIND_EARLY} + */ + public byte getKind() { + return kind; + } + + /** + * Returns the remote version reported by Spigot when applicable. + * + * @return remote version for {@link #KIND_NEWER}, otherwise {@code null} + */ + public @Nullable String getRemoteVersion() { + return remoteVersion; + } + + /** + * Returns the local plugin version captured when the notice was created. + * + * @return local plugin version for update-related notices, otherwise {@code null} + */ + public @Nullable String getLocalVersion() { + return localVersion; + } + } } diff --git a/src/main/java/com/bitaspire/cyberlevels/DatabaseFactory.java b/src/main/java/com/bitaspire/cyberlevels/DatabaseFactory.java index 882cba9..40d29b1 100644 --- a/src/main/java/com/bitaspire/cyberlevels/DatabaseFactory.java +++ b/src/main/java/com/bitaspire/cyberlevels/DatabaseFactory.java @@ -11,12 +11,16 @@ import java.sql.*; import java.util.*; +import java.util.concurrent.CompletableFuture; @UtilityClass class DatabaseFactory { abstract static class DatabaseImpl implements Database { + private static final String MYSQL_CHARSET = "utf8mb4"; + private static final String MYSQL_COLLATION = "utf8mb4_unicode_ci"; + final CyberLevels main; final BaseSystem system; @@ -40,16 +44,8 @@ String qTab(String name) { return name; } - abstract PreparedStatement prepareUpsert(Connection c, - UUID uuid, - long level, - String exp, - long updatedAt) throws SQLException; - - abstract PreparedStatement prepareUpsertMeta(Connection c, - UUID uuid, - long highestRewarded, - long updatedAt) throws SQLException; + abstract PreparedStatement prepareUpsert(Connection c, UUID uuid, long level, String exp, long updatedAt) throws SQLException; + abstract PreparedStatement prepareUpsertMeta(Connection c, UUID uuid, long highestRewarded, long updatedAt) throws SQLException; abstract Set getExistingColumns(Connection conn) throws SQLException; abstract boolean isExpColumnTextual(Connection conn) throws SQLException; @@ -63,21 +59,74 @@ String metaTable() { return getTable() + "_meta"; } + boolean isMySqlFamily() { + return this instanceof MySQL; + } + + String joinUuidOperand(String alias) { + String column = alias + "." + qCol("UUID"); + if (!isMySqlFamily()) return column; + + return "CONVERT(" + column + " USING " + MYSQL_CHARSET + ") COLLATE " + MYSQL_COLLATION; + } + void ensureMetaSchema(Connection conn) throws SQLException { final boolean sqlite = (this instanceof SQLite); String idType = sqlite ? "TEXT" : "VARCHAR(36)"; String longType = sqlite ? "INTEGER" : "BIGINT"; + String suffix = isMySqlFamily() ? + " CHARSET=" + MYSQL_CHARSET + " COLLATE=" + MYSQL_COLLATION : + ""; String sql = "CREATE TABLE IF NOT EXISTS " + qTab(metaTable()) + " (" + qCol("UUID") + " " + idType + " PRIMARY KEY," + qCol("HIGHEST_REWARDED") + " " + longType + "," + qCol("UPDATED_AT") + " " + longType + " NOT NULL DEFAULT 0" + - ")"; + ")" + suffix; try (Statement st = conn.createStatement()) { st.executeUpdate(sql); } } + void ensureCollationCompatibility(Connection conn) { + if (!isMySqlFamily()) return; + + ensureMySqlTableCollation(conn, getTable()); + ensureMySqlTableCollation(conn, metaTable()); + } + + private void ensureMySqlTableCollation(Connection conn, String table) { + try { + if (!tableExists(conn, table)) return; + + String current = currentMySqlTableCollation(conn, table); + if (current != null && current.equalsIgnoreCase(MYSQL_COLLATION)) return; + + try (Statement st = conn.createStatement()) { + st.executeUpdate( + "ALTER TABLE " + qTab(table) + + " CONVERT TO CHARACTER SET " + MYSQL_CHARSET + + " COLLATE " + MYSQL_COLLATION + ); + } + + main.logger("&e" + type + ": normalized collation for table '" + table + "' to " + MYSQL_COLLATION + "."); + } catch (SQLException e) { + main.logger("&e" + type + ": unable to normalize collation for table '" + table + "'. Queries will use a compatibility path instead."); + } + } + + private String currentMySqlTableCollation(Connection conn, String table) throws SQLException { + String sql = "SELECT TABLE_COLLATION FROM information_schema.TABLES " + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, table); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + long readHighestRewarded(Connection conn, UUID uuid) { String sql = "SELECT " + qCol("HIGHEST_REWARDED") + " FROM " + qTab(metaTable()) + " WHERE " + qCol("UUID") + "=?"; @@ -108,12 +157,20 @@ public void connect() { try (Connection conn = dataSource.getConnection()) { ensureTargetSchema(conn); ensureMetaSchema(conn); + ensureCollationCompatibility(conn); } - main.logger("&7Connected to &e" + type + "&7 successfully in &a" + (System.currentTimeMillis() - l) + "ms&7.", ""); + main.logger("&7Connected to &e" + type + "&7 successfully in &a" + (System.currentTimeMillis() - l) + "ms&7."); } catch (Exception e) { - main.logger("&cThere was an issue connecting to " + type + " Database.", ""); + main.logger("&cThere was an issue connecting to " + type + " Database."); e.printStackTrace(); + + if (dataSource != null) { + try { + dataSource.close(); + } catch (Exception ignored) {} + dataSource = null; + } } } @@ -123,13 +180,40 @@ public void disconnect() { main.logger("&dAttempting to disconnect from " + type + "..."); long l = System.currentTimeMillis(); + + Runnable close = () -> { + try { + dataSource.close(); + main.logger("&7Disconnected from &e" + type + "&7 successfully in &a" + (System.currentTimeMillis() - l) + "ms&7."); + } catch (Exception e) { + main.logger("&cThere was an issue disconnecting from " + type + " Database."); + e.printStackTrace(); + } finally { + dataSource = null; + } + }; + + if (main.isEnabled()) { + main.scheduler().runTaskAsynchronously(close); + } else { + close.run(); + } + } + + void disconnectSync() { + if (!isConnected()) return; + + main.logger("&dAttempting to disconnect from " + type + "..."); + long l = System.currentTimeMillis(); + try { dataSource.close(); - dataSource = null; - main.logger("&7Disconnected from &e" + type + "&7 successfully in &a" + (System.currentTimeMillis() - l) + "ms&7.", ""); + main.logger("&7Disconnected from &e" + type + "&7 successfully in &a" + (System.currentTimeMillis() - l) + "ms&7."); } catch (Exception e) { - main.logger("&cThere was an issue disconnecting from " + type + " Database.", ""); + main.logger("&cThere was an issue disconnecting from " + type + " Database."); e.printStackTrace(); + } finally { + dataSource = null; } } @@ -270,6 +354,105 @@ static class Row { } } + static final class StoredUserData { + final UUID uuid; + final long level; + final String exp; + final long highestRewarded; + final long updatedAt; + + StoredUserData(UUID uuid, long level, String exp, long highestRewarded, long updatedAt) { + this.uuid = uuid; + this.level = level; + this.exp = exp; + this.highestRewarded = highestRewarded; + this.updatedAt = updatedAt; + } + } + + private String selectStoredUserSql(String whereClause) { + return "SELECT t." + qCol("UUID") + " AS UUID," + + " t." + qCol("LEVEL") + " AS LEVEL," + + " t." + qCol("EXP") + " AS EXP," + + " t." + qCol("UPDATED_AT") + " AS UPDATED_AT," + + " m." + qCol("HIGHEST_REWARDED") + " AS META_HIGHEST_REWARDED," + + " m." + qCol("UPDATED_AT") + " AS META_UPDATED_AT " + + "FROM " + qTab(getTable()) + " t " + + "LEFT JOIN " + qTab(metaTable()) + " m ON " + joinUuidOperand("t") + " = " + joinUuidOperand("m") + + " " + whereClause; + } + + private StoredUserData readStoredUserData(ResultSet rs) throws SQLException { + UUID uuid = UUID.fromString(rs.getString("UUID")); + long level = parseLevel(rs.getString("LEVEL"), uuid); + String exp = parseExp(rs.getString("EXP"), uuid); + + long highest = safeGetLong(rs, "META_HIGHEST_REWARDED", level); + if (highest < 0) highest = level; + + long updatedAt = Math.max( + safeGetLong(rs, "UPDATED_AT", 0L), + safeGetLong(rs, "META_UPDATED_AT", 0L) + ); + + return new StoredUserData(uuid, level, exp, highest, updatedAt); + } + + StoredUserData fetchUserData(UUID uuid) { + if (!isConnected() || uuid == null) return null; + + String sql = selectStoredUserSql("WHERE t." + qCol("UUID") + "=?"); + try (Connection connection = dataSource.getConnection(); + PreparedStatement st = connection.prepareStatement(sql)) { + st.setString(1, uuid.toString()); + + try (ResultSet rs = st.executeQuery()) { + if (!rs.next()) return null; + return readStoredUserData(rs); + } + } catch (Exception e) { + main.logger("&cFailed to get player data for " + uuid + ".", ""); + e.printStackTrace(); + return null; + } + } + + List getUsersUpdatedSince(long updatedAfter) { + if (!isConnected()) return Collections.emptyList(); + + String sql = selectStoredUserSql( + "WHERE t." + qCol("UPDATED_AT") + " > ? OR m." + qCol("UPDATED_AT") + " > ?" + ); + + try (Connection connection = dataSource.getConnection(); + PreparedStatement st = connection.prepareStatement(sql)) { + st.setLong(1, updatedAfter); + st.setLong(2, updatedAfter); + + try (ResultSet rs = st.executeQuery()) { + List users = new ArrayList<>(); + while (rs.next()) { + try { + users.add(readStoredUserData(rs)); + } catch (Exception ignored) {} + } + return users; + } + } catch (Exception e) { + main.logger("&cFailed to fetch recent player updates from " + type + ".", ""); + e.printStackTrace(); + return Collections.emptyList(); + } + } + + LevelUser toLevelUser(StoredUserData data) { + if (data == null) return null; + + LevelUser user = system.createUser(data.uuid); + system.applyStoredState(user, data.level, data.exp, data.highestRewarded); + return user; + } + static String safeGet(ResultSet rs, String col) { try { return rs.getString(col); @@ -317,18 +500,28 @@ static double safeGetDouble(ResultSet rs) { @Override public boolean isUserLoaded(LevelUser user) { if (!isConnected()) return false; - try (Connection connection = dataSource.getConnection(); - PreparedStatement statement = connection.prepareStatement( - "SELECT 1 FROM " + qTab(getTable()) + " WHERE " + qCol("UUID") + "=?")) { - statement.setString(1, user.getUuid().toString()); - try (ResultSet rs = statement.executeQuery()) { - return rs.next(); + CompletableFuture future = new CompletableFuture<>(); + + main.scheduler().runTaskAsynchronously(() -> { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT 1 FROM " + qTab(getTable()) + " WHERE " + qCol("UUID") + "=?")) { + statement.setString(1, user.getUuid().toString()); + try (ResultSet rs = statement.executeQuery()) { + future.complete(rs.next()); + } + } catch (Exception e) { + main.logger("&cFailed to check if user exists in table."); + e.printStackTrace(); + future.complete(false); } + }); + + try { + return future.get(); } catch (Exception e) { - main.logger("&cFailed to check if user exists in table."); - e.printStackTrace(); + return false; } - return false; } void setRewardLevel(LevelUser user, long level) { @@ -345,6 +538,47 @@ long getRewardLevel(LevelUser user) { } } + private long parseLevel(String raw, UUID uuid) { + long fallback = system.getStartLevel(); + if (raw == null) return fallback; + + String value = raw.trim(); + if (value.isEmpty()) return fallback; + + try { + return Long.parseLong(value); + } catch (Exception ignored) { + main.logger("&eInvalid level value '" + value + "' for " + uuid + " in " + type + " database. Using " + fallback + "."); + return fallback; + } + } + + private String parseExp(String raw, UUID uuid) { + String fallback = String.valueOf(system.getStartExp()); + if (raw == null) return fallback; + + String value = raw.trim(); + if (value.isEmpty()) return fallback; + + try { + system.getOperator().valueOf(value); + return value; + } catch (Exception ignored) { + main.logger("&eInvalid exp value '" + value + "' for " + uuid + " in " + type + " database. Using " + fallback + "."); + return fallback; + } + } + + private void upsertUser(Connection connection, UUID uuid, long level, String expStr, long highest, long now) throws SQLException { + try (PreparedStatement st = prepareUpsert(connection, uuid, level, expStr, now)) { + st.executeUpdate(); + } + + try (PreparedStatement stm = prepareUpsertMeta(connection, uuid, highest, now)) { + stm.executeUpdate(); + } + } + @Override public void addUser(LevelUser user, boolean defValues) { if (!isConnected()) return; @@ -358,52 +592,69 @@ public void addUser(LevelUser user, boolean defValues) { expStr = String.valueOf(user.getExp()); // already string-ish } - String sql = "INSERT INTO " + qTab(getTable()) + " (" + - qCol("UUID") + "," + qCol("LEVEL") + "," + qCol("EXP") + "," + qCol("UPDATED_AT") + - ") VALUES (?,?,?,?)"; + final String finalLevelStr = levelStr; + final String finalExpStr = expStr; - try (Connection connection = dataSource.getConnection(); - PreparedStatement st = connection.prepareStatement(sql)) { - st.setString(1, user.getUuid().toString()); - st.setLong(2, Long.parseLong(levelStr)); - st.setString(3, expStr); - st.setLong(4, System.currentTimeMillis()); - st.executeUpdate(); + main.scheduler().runTaskAsynchronously(() -> { + String sql = "INSERT INTO " + qTab(getTable()) + " (" + + qCol("UUID") + "," + qCol("LEVEL") + "," + qCol("EXP") + "," + qCol("UPDATED_AT") + + ") VALUES (?,?,?,?)"; - long now = System.currentTimeMillis(); - long highest = getRewardLevel(user); - try (PreparedStatement pm = prepareUpsertMeta(connection, user.getUuid(), highest, now)) { - pm.executeUpdate(); - } - } catch (Exception e) { - main.logger("&cFailed to add user " + user.getName() + "."); - e.printStackTrace(); - } + try (Connection connection = dataSource.getConnection(); + PreparedStatement st = connection.prepareStatement(sql)) + { + st.setString(1, user.getUuid().toString()); + st.setLong(2, Long.parseLong(finalLevelStr)); + st.setString(3, finalExpStr); + st.setLong(4, System.currentTimeMillis()); + st.executeUpdate(); + + long now = System.currentTimeMillis(); + long highest = getRewardLevel(user); + try (PreparedStatement pm = prepareUpsertMeta(connection, user.getUuid(), highest, now)) { + pm.executeUpdate(); + } + } catch (Exception e) { + main.logger("&cFailed to add user " + user.getName() + "."); + e.printStackTrace(); + } + }); } @Override public void updateUser(LevelUser user) { if (!isConnected()) return; + final UUID uuid = user.getUuid(); + final long now = System.currentTimeMillis(); + final String expStr = String.valueOf(user.getExp()); + final long level = user.getLevel(); + final String name = user.getName(); + final long highest = getRewardLevel(user); + + main.scheduler().runTaskAsynchronously(() -> { + try (Connection connection = dataSource.getConnection()) { + upsertUser(connection, uuid, level, expStr, highest, now); + } catch (Exception e) { + main.logger("&cFailed to update user " + name + "."); + e.printStackTrace(); + } + }); + } + + @Override + public void updateUserSync(LevelUser user) { + if (!isConnected()) return; + UUID uuid = user.getUuid(); long now = System.currentTimeMillis(); + String expStr = String.valueOf(user.getExp()); + long level = user.getLevel(); + long highest = getRewardLevel(user); try (Connection connection = dataSource.getConnection()) { - try (PreparedStatement st = prepareUpsert( - connection, - uuid, - user.getLevel(), - String.valueOf(user.getExp()), - now - )) { - st.executeUpdate(); - } - - long highest = getRewardLevel(user); - try (PreparedStatement stm = prepareUpsertMeta(connection, uuid, highest, now)) { - stm.executeUpdate(); - } + upsertUser(connection, uuid, level, expStr, highest, now); } catch (Exception e) { - main.logger("&cFailed to update user " + user.getName() + "."); + main.logger("&cFailed to update user " + user.getName() + " synchronously."); e.printStackTrace(); } } @@ -411,20 +662,23 @@ public void updateUser(LevelUser user) { @Override public void removeUser(UUID uuid) { if (!isConnected()) return; - String sql = "DELETE FROM " + qTab(getTable()) + " WHERE " + qCol("UUID") + "=?"; - String metaSql = "DELETE FROM " + qTab(metaTable()) + " WHERE " + qCol("UUID") + "=?"; - try (Connection connection = dataSource.getConnection(); - PreparedStatement st = connection.prepareStatement(sql); - PreparedStatement sm = connection.prepareStatement(metaSql)) { - st.setString(1, uuid.toString()); - st.executeUpdate(); - sm.setString(1, uuid.toString()); - sm.executeUpdate(); - } catch (Exception e) { - main.logger("&cFailed to remove user " + uuid + " from " + type + " database."); - e.printStackTrace(); - } + main.scheduler().runTaskAsynchronously(() -> { + String sql = "DELETE FROM " + qTab(getTable()) + " WHERE " + qCol("UUID") + "=?"; + String metaSql = "DELETE FROM " + qTab(metaTable()) + " WHERE " + qCol("UUID") + "=?"; + try (Connection connection = dataSource.getConnection(); + PreparedStatement st = connection.prepareStatement(sql); + PreparedStatement sm = connection.prepareStatement(metaSql)) { + st.setString(1, uuid.toString()); + st.executeUpdate(); + + sm.setString(1, uuid.toString()); + sm.executeUpdate(); + } catch (Exception e) { + main.logger("&cFailed to remove user " + uuid + " from " + type + " database."); + e.printStackTrace(); + } + }); } @Override @@ -436,31 +690,15 @@ public LevelUser getUser(Player player) { public LevelUser getUser(UUID uuid) { if (!isConnected() || uuid == null) return null; - String sql = "SELECT " + qCol("LEVEL") + "," + qCol("EXP") + " FROM " + qTab(getTable()) + " WHERE " + qCol("UUID") + "=?"; + CompletableFuture> future = new CompletableFuture<>(); - try (Connection connection = dataSource.getConnection(); - PreparedStatement st = connection.prepareStatement(sql)) { - st.setString(1, uuid.toString()); + main.scheduler().runTaskAsynchronously(() -> { + future.complete(toLevelUser(fetchUserData(uuid))); + }); - try (ResultSet rs = st.executeQuery()) { - if (!rs.next()) return null; - - LevelUser user = system.createUser(uuid); - long level = rs.getLong("LEVEL"); - user.setLevel(level, false); - - String expStr = rs.getString("EXP"); - if (expStr == null) expStr = "0"; - user.setExp(expStr, false, false, false); - - long hr = readHighestRewarded(connection, uuid); - setRewardLevel(user, hr >= 0 ? hr : user.getLevel()); - - return user; - } + try { + return future.get(); } catch (Exception e) { - main.logger("&cFailed to get player data for " + uuid + ".", ""); - e.printStackTrace(); return null; } } @@ -470,25 +708,37 @@ public Set getUuids() { Set uuids = new LinkedHashSet<>(); if (!isConnected()) return uuids; - String sql = "SELECT " + qCol("UUID") + " FROM " + qTab(getTable()); - try (Connection connection = dataSource.getConnection(); - PreparedStatement statement = connection.prepareStatement(sql); - ResultSet rs = statement.executeQuery()) { - while (rs.next()) { - try { - uuids.add(UUID.fromString(rs.getString("UUID"))); - } catch (Exception ignored) {} + CompletableFuture> future = new CompletableFuture<>(); + + main.scheduler().runTaskAsynchronously(() -> { + String sql = "SELECT " + qCol("UUID") + " FROM " + qTab(getTable()); + + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet rs = statement.executeQuery()) + { + Set result = new LinkedHashSet<>(); + while (rs.next()) { + try { + result.add(UUID.fromString(rs.getString("UUID"))); + } catch (Exception ignored) {} + } + future.complete(result); + } catch (SQLException e) { + main.logger("&cFailed to fetch UUIDs from " + type + "."); + e.printStackTrace(); + future.complete(new LinkedHashSet<>()); } - } catch (SQLException e) { - main.logger("&cFailed to fetch UUIDs from " + type + "."); - e.printStackTrace(); + }); + + try { + return future.get(); + } catch (Exception e) { + return uuids; } - return uuids; } } - // --------------- MySQL / MariaDB --------------- // - static class MySQL extends DatabaseImpl { final String ip, database, username, password, table; @@ -518,9 +768,20 @@ HikariConfig createConfig() { config.setJdbcUrl("jdbc:mysql://" + ip + ":" + port + "/" + database + "?useSSL=" + ssl + "&autoReconnect=true&useUnicode=true&characterEncoding=utf8"); config.setUsername(username); config.setPassword(password); - config.setMaximumPoolSize(10); - config.setMinimumIdle(2); + config.setConnectionTimeout(10000); + config.setValidationTimeout(5000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setKeepaliveTime(300000); + config.setMaximumPoolSize(20); + config.setMinimumIdle(4); config.setPoolName("CLV-MySQL"); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + config.addDataSourceProperty("useServerPrepStmts", "true"); + config.addDataSourceProperty("tcpKeepAlive", "true"); + config.setTransactionIsolation("TRANSACTION_READ_COMMITTED"); return config; } @@ -645,8 +906,6 @@ void renameTable(Connection conn, String from, String to) throws SQLException { } } - // --------------- SQLite --------------- // - static class SQLite extends DatabaseImpl { private final String filePath, table; @@ -663,8 +922,14 @@ static class SQLite extends DatabaseImpl { @Override HikariConfig createConfig() { HikariConfig config = new HikariConfig(); - config.setJdbcUrl("jdbc:sqlite:" + filePath); - config.setMaximumPoolSize(1); + config.setJdbcUrl("jdbc:sqlite:" + filePath + "?busy_timeout=5000&journal_mode=WAL&synchronous=NORMAL"); + config.setMaximumPoolSize(2); + config.setMinimumIdle(1); + config.setConnectionTimeout(10000); + config.setValidationTimeout(5000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setKeepaliveTime(300000); config.setPoolName("CLV-SQLite"); return config; } @@ -781,8 +1046,6 @@ void renameTable(Connection conn, String from, String to) throws SQLException { } } - // --------------- PostgreSQL --------------- // - static class PostgreSQL extends DatabaseImpl { final String ip, database, username, password, table; @@ -809,9 +1072,15 @@ HikariConfig createConfig() { config.setJdbcUrl("jdbc:postgresql://" + ip + ":" + port + "/" + database); config.setUsername(username); config.setPassword(password); - config.setMaximumPoolSize(10); - config.setMinimumIdle(2); + config.setConnectionTimeout(10000); + config.setValidationTimeout(5000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setKeepaliveTime(300000); + config.setMaximumPoolSize(20); + config.setMinimumIdle(4); config.setPoolName("CLV-Postgres"); + config.addDataSourceProperty("tcpKeepAlive", "true"); return config; } @@ -936,6 +1205,230 @@ void renameTable(Connection conn, String from, String to) throws SQLException { } } + static class H2 extends DatabaseImpl { + + private final String filePath, table; + + H2(CyberLevels main, BaseSystem system) { + super(main, system, "H2"); + Config.Database db = main.cache().config().database(); + this.filePath = db.getH2File(); + this.table = db.getTable(); + } + + @Override String getTable() { return table; } + + @Override + HikariConfig createConfig() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:file:" + filePath + + ";MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE;CASE_INSENSITIVE_IDENTIFIERS=TRUE" + + ";DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=TRUE"); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + config.setConnectionTimeout(10000); + config.setValidationTimeout(5000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setKeepaliveTime(300000); + config.setPoolName("CLV-H2"); + return config; + } + + @Override + String qCol(String name) { + return "\"" + name + "\""; + } + + @Override + String qTab(String name) { + return "\"" + name + "\""; + } + + private PreparedStatement prepareMerge(Connection c, + String targetTable, + String[] cols, + String keyCol, + String[] caseCols, + String maxCol, + Object[] values) throws SQLException { + StringBuilder source = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) source.append(','); + source.append('?'); + } + + StringBuilder colsList = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) colsList.append(','); + colsList.append(qCol(cols[i])); + } + + StringBuilder srcCols = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) srcCols.append(','); + srcCols.append(qCol(cols[i])); + } + + StringBuilder updateClause = new StringBuilder(); + for (int i = 0; i < caseCols.length; i++) { + if (i > 0) updateClause.append(','); + String col = caseCols[i]; + updateClause.append(qCol(col)).append(" = CASE WHEN s.").append(qCol(maxCol)) + .append(" >= t.").append(qCol(maxCol)).append(" THEN s.").append(qCol(col)) + .append(" ELSE t.").append(qCol(col)).append(" END"); + } + if (updateClause.length() > 0) updateClause.append(','); + updateClause.append(qCol(maxCol)).append(" = CASE WHEN s.").append(qCol(maxCol)) + .append(" > t.").append(qCol(maxCol)).append(" THEN s.").append(qCol(maxCol)) + .append(" ELSE t.").append(qCol(maxCol)).append(" END"); + + String sql = "MERGE INTO " + qTab(targetTable) + " t " + + "USING (SELECT " + buildSelectAliasList(cols) + " FROM (VALUES (" + source + ")) v(" + srcCols + ")) s " + + "ON t." + qCol(keyCol) + " = s." + qCol(keyCol) + " " + + "WHEN MATCHED THEN UPDATE SET " + updateClause + " " + + "WHEN NOT MATCHED THEN INSERT (" + colsList + ") VALUES (" + buildSourceRefList(cols) + ")"; + + PreparedStatement ps = c.prepareStatement(sql); + for (int i = 0; i < values.length; i++) { + Object v = values[i]; + if (v instanceof Long) ps.setLong(i + 1, (Long) v); + else ps.setString(i + 1, String.valueOf(v)); + } + return ps; + } + + private String buildSelectAliasList(String[] cols) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) sb.append(','); + sb.append("v.").append(qCol(cols[i])).append(" AS ").append(qCol(cols[i])); + } + return sb.toString(); + } + + private String buildSourceRefList(String[] cols) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) sb.append(','); + sb.append("s.").append(qCol(cols[i])); + } + return sb.toString(); + } + + @Override + PreparedStatement prepareUpsert(Connection c, UUID uuid, long level, String exp, long updatedAt) throws SQLException { + String[] cols = { "UUID", "LEVEL", "EXP", "UPDATED_AT" }; + String[] caseCols = { "LEVEL", "EXP" }; + Object[] values = { uuid.toString(), level, exp, updatedAt }; + return prepareMerge(c, getTable(), cols, "UUID", caseCols, "UPDATED_AT", values); + } + + @Override + PreparedStatement prepareUpsertMeta(Connection c, UUID uuid, long highest, long updatedAt) throws SQLException { + String sql = "MERGE INTO " + qTab(metaTable()) + " t " + + "USING (SELECT v." + qCol("UUID") + " AS " + qCol("UUID") + "," + + " v." + qCol("HIGHEST_REWARDED") + " AS " + qCol("HIGHEST_REWARDED") + "," + + " v." + qCol("UPDATED_AT") + " AS " + qCol("UPDATED_AT") + + " FROM (VALUES (?,?,?)) v(" + qCol("UUID") + "," + qCol("HIGHEST_REWARDED") + "," + qCol("UPDATED_AT") + ")) s " + + "ON t." + qCol("UUID") + " = s." + qCol("UUID") + " " + + "WHEN MATCHED THEN UPDATE SET " + + qCol("HIGHEST_REWARDED") + " = CASE WHEN s." + qCol("HIGHEST_REWARDED") + " > t." + qCol("HIGHEST_REWARDED") + + " THEN s." + qCol("HIGHEST_REWARDED") + " ELSE t." + qCol("HIGHEST_REWARDED") + " END," + + qCol("UPDATED_AT") + " = CASE WHEN s." + qCol("UPDATED_AT") + " > t." + qCol("UPDATED_AT") + + " THEN s." + qCol("UPDATED_AT") + " ELSE t." + qCol("UPDATED_AT") + " END " + + "WHEN NOT MATCHED THEN INSERT (" + qCol("UUID") + "," + qCol("HIGHEST_REWARDED") + "," + qCol("UPDATED_AT") + + ") VALUES (s." + qCol("UUID") + ", s." + qCol("HIGHEST_REWARDED") + ", s." + qCol("UPDATED_AT") + ")"; + PreparedStatement ps = c.prepareStatement(sql); + ps.setString(1, uuid.toString()); + ps.setLong(2, highest); + ps.setLong(3, updatedAt); + return ps; + } + + @Override + Set getExistingColumns(Connection conn) throws SQLException { + Set cols = new HashSet<>(); + String sql = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_SCHEMA) = UPPER(SCHEMA()) AND UPPER(TABLE_NAME) = UPPER(?)"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, getTable()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) cols.add(rs.getString(1).toUpperCase(Locale.ENGLISH)); + } + } + return cols; + } + + @Override + boolean isExpColumnTextual(Connection conn) throws SQLException { + String sql = "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_SCHEMA) = UPPER(SCHEMA()) AND UPPER(TABLE_NAME) = UPPER(?) " + + "AND UPPER(COLUMN_NAME) = 'EXP'"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, getTable()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + String type = rs.getString(1); + if (type == null) return false; + type = type.toLowerCase(Locale.ENGLISH); + return type.contains("char") || type.contains("text") || type.contains("clob") || + type.contains("varchar"); + } + } + } + return false; + } + + @Override + boolean hasPrimaryKeyOnUuid(Connection conn) throws SQLException { + String sql = "SELECT kcu.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc " + + "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " + + "ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA " + + "AND tc.TABLE_NAME = kcu.TABLE_NAME " + + "WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' " + + "AND UPPER(tc.TABLE_SCHEMA) = UPPER(SCHEMA()) " + + "AND UPPER(tc.TABLE_NAME) = UPPER(?)"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, getTable()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String col = rs.getString(1); + if ("UUID".equalsIgnoreCase(col)) return true; + } + } + } + return false; + } + + @Override + void createTargetTable(Connection conn) throws SQLException { + String sql = "CREATE TABLE IF NOT EXISTS " + qTab(getTable()) + " (" + + qCol("UUID") + " VARCHAR(36) NOT NULL," + + qCol("LEVEL") + " BIGINT," + + qCol("EXP") + " VARCHAR," + + qCol("UPDATED_AT") + " BIGINT NOT NULL DEFAULT 0," + + "PRIMARY KEY (" + qCol("UUID") + "))"; + try (Statement st = conn.createStatement()) { + st.executeUpdate(sql); + } + } + + @Override + void dropTableIfExists(Connection conn, String table) throws SQLException { + try (Statement st = conn.createStatement()) { + st.executeUpdate("DROP TABLE IF EXISTS " + qTab(table)); + } + } + + @Override + void renameTable(Connection conn, String from, String to) throws SQLException { + try (Statement st = conn.createStatement()) { + st.executeUpdate("ALTER TABLE " + qTab(from) + " RENAME TO " + qTab(to)); + } + } + } + static Database createDatabase(CyberLevels main, BaseSystem system) { String type = main.cache().config().database().getType(); @@ -946,6 +1439,8 @@ static Database createDatabase(CyberLevels main, BaseSyste case "MYSQL": case "MARIADB": return new MySQL<>(main, system); + case "H2": + return new H2<>(main, system); case "SQLITE": default: return new SQLite<>(main, system); diff --git a/src/main/java/com/bitaspire/cyberlevels/DependencyLoader.java b/src/main/java/com/bitaspire/cyberlevels/DependencyLoader.java new file mode 100644 index 0000000..1363730 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/DependencyLoader.java @@ -0,0 +1,256 @@ +package com.bitaspire.cyberlevels; + +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.UtilityClass; +import com.bitaspire.libs.file.YAMLFile; +import org.apache.commons.lang3.StringUtils; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import sun.misc.Unsafe; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.logging.Level; + +class DependencyLoader { + + static final DependencyLoader BUKKIT_LOADER = + new DependencyLoader(Bukkit.getWorldContainer(), "libraries") { + @Override + public void setComplexStructure(boolean complex) { + throw new IllegalStateException("Structure can't be changed."); + } + }; + + static final String[] MAVEN_REPO_URLS = { + "https://repo1.maven.org/maven2/", + "https://repo.maven.apache.org/maven2/" + }; + + private final File librariesFolder; + + @Setter + private boolean complexStructure = true; + + private DependencyLoader(File folder, String newName) { + this.librariesFolder = StringUtils.isBlank(newName) ? folder : new File(folder, newName); + } + + private void log(Log log, String message) { + Bukkit.getLogger().log(log.level, "[DependencyLoader] " + message); + } + + private boolean downloadFile(String urlString, File destiny) throws IOException { + URL url = new URL(urlString); + + try { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + log(Log.ERROR, "URL not reachable: " + urlString); + return false; + } + } catch (Exception e) { + log(Log.ERROR, "URL not reachable: " + urlString); + e.printStackTrace(); + return false; + } + + try (FileOutputStream out = new FileOutputStream(destiny); + InputStream in = url.openStream()) { + byte[] buffer = new byte[4096]; + int len; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + return true; + } + } + + public final boolean load(String group, String artifact, String version, String repoUrl, boolean replace) { + repoUrl = repoUrl != null ? repoUrl : MAVEN_REPO_URLS[0]; + + try { + String path = group.replace('.', File.separatorChar) + File.separatorChar + + artifact + File.separatorChar + version; + String jarName = artifact + "-" + version + ".jar"; + + File jarFile = new File(librariesFolder, + (complexStructure ? (path + File.separatorChar) : "") + jarName); + if (!jarFile.exists() || replace) { + if (jarFile.exists()) jarFile.delete(); + + log(Log.GOOD, "Downloading: " + jarName); + jarFile.getParentFile().mkdirs(); + + StringBuilder builder = new StringBuilder(repoUrl); + if (!repoUrl.endsWith("/")) builder.append('/'); + + builder.append(path.replace(File.separatorChar, '/')) + .append('/') + .append(jarName); + + if (!downloadFile(builder.toString(), jarFile)) + return false; + + if (jarFile.length() == 0) { + log(Log.ERROR, "Download failed or file is empty: " + jarName); + return false; + } + } + + Utils.load0(jarFile); + log(Log.GOOD, "Loaded: " + jarName); + return true; + } catch (Exception e) { + log(Log.ERROR, "Error loading dependency: " + artifact + " v" + version); + e.printStackTrace(); + return false; + } + } + + public final boolean load(String group, String artifact, String version, boolean replace) { + return load(group, artifact, version, null, replace); + } + + public final boolean load(String group, String artifact, String version, String repoUrl) { + return load(group, artifact, version, repoUrl, false); + } + + public final boolean load(String group, String artifact, String version) { + return load(group, artifact, version, false); + } + + public boolean loadFromConfiguration(FileConfiguration c) { + List> dependencies = c.getMapList("dependencies"); + boolean loadAtLeastOne = false; + + for (Map map : dependencies) { + String group = (String) map.get("group"); + String artifact = (String) map.get("artifact"); + String version = (String) map.get("version"); + + if (group == null || artifact == null || version == null) { + log(Log.BAD, "Invalid dependency: " + map); + continue; + } + + Boolean replace = (Boolean) map.get("replace"); + loadAtLeastOne = load( + group, artifact, version, (String) map.get("repo"), + replace != null && replace + ); + } + + return loadAtLeastOne; + } + + public boolean loadFromFile(File file) { + if (!file.getAbsolutePath().endsWith(".yml")) { + log(Log.BAD, file + " isn't a valid .yml file."); + return false; + } + + if (!file.exists()) { + log(Log.BAD, file + " doesn't exist."); + return false; + } + + FileConfiguration config = YamlConfiguration.loadConfiguration(file); + return loadFromConfiguration(config); + } + + public boolean loadFromYAML(YAMLFile file) { + return loadFromConfiguration(file.getConfiguration()); + } + + public static DependencyLoader fromFolder(File librariesFolder, String folderName) { + return new DependencyLoader(librariesFolder, folderName); + } + + public static DependencyLoader fromFolder(File librariesFolder) { + return fromFolder(librariesFolder, null); + } + + @RequiredArgsConstructor + private enum Log { + GOOD(Level.INFO), + BAD(Level.WARNING), + ERROR(Level.SEVERE); + + private final Level level; + } + + @UtilityClass + private static class Utils { + + private final Unsafe THE_UNSAFE = ((Supplier) () -> { + Field field; + try { + field = Unsafe.class.getDeclaredField("theUnsafe"); + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + field.setAccessible(true); + try { + return (Unsafe) field.get(null); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }).get(); + + Field findField(Class clazz, String field) { + try { + return clazz.getDeclaredField(field); + } catch (Exception e) { + Class s = clazz.getSuperclass(); + return s == null ? null : findField(s, field); + } + } + + @SuppressWarnings("unchecked") + void load0(File file) throws Exception { + final ClassLoader mainLoader = DependencyLoader.class.getClassLoader(); + + Field field = findField(mainLoader.getClass(), "ucp"); + if (field == null) + throw new IllegalStateException("Couldn't find URLClassLoader field 'ucp'"); + + long offset = THE_UNSAFE.objectFieldOffset(field); + Object ucp = THE_UNSAFE.getObject(mainLoader, offset); + + field = ucp.getClass().getDeclaredField("path"); + offset = THE_UNSAFE.objectFieldOffset(field); + Collection paths = (Collection) THE_UNSAFE.getObject(ucp, offset); + + try { + field = ucp.getClass().getDeclaredField("unopenedUrls"); + } catch (NoSuchFieldException e) { + field = ucp.getClass().getDeclaredField("urls"); + } + + offset = THE_UNSAFE.objectFieldOffset(field); + Collection urls = (Collection) THE_UNSAFE.getObject(ucp, offset); + + URL url = file.toURI().toURL(); + if (paths.contains(url)) return; + + paths.add(url); + urls.add(url); + } + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/DoubleLevelSystem.java b/src/main/java/com/bitaspire/cyberlevels/DoubleSystem.java similarity index 88% rename from src/main/java/com/bitaspire/cyberlevels/DoubleLevelSystem.java rename to src/main/java/com/bitaspire/cyberlevels/DoubleSystem.java index 69813e1..6f16559 100644 --- a/src/main/java/com/bitaspire/cyberlevels/DoubleLevelSystem.java +++ b/src/main/java/com/bitaspire/cyberlevels/DoubleSystem.java @@ -1,26 +1,27 @@ package com.bitaspire.cyberlevels; import com.bitaspire.cyberlevels.user.UserManager; -import com.bitaspire.libs.formula.DoubleExpressionBuilder; import com.bitaspire.cyberlevels.level.Formula; import com.bitaspire.cyberlevels.level.Operator; import com.bitaspire.cyberlevels.user.LevelUser; -import com.bitaspire.libs.formula.expression.ExpressionBuilder; import lombok.Getter; +import me.croabeast.expr4j.DoubleBuilder; +import me.croabeast.expr4j.expression.Builder; import org.jetbrains.annotations.NotNull; import java.math.RoundingMode; @Getter -final class DoubleLevelSystem extends BaseSystem { +final class DoubleSystem extends BaseSystem { - private final Operator operator; - - DoubleLevelSystem(CyberLevels main) { + DoubleSystem(CyberLevels main) { super(main); setLeaderboardFunction(DoubleLeaderboard::new); + } - operator = new Operator() { + @Override + Operator createOperator() { + return new Operator() { @Override public Double zero() { return 0.0; @@ -130,10 +131,10 @@ public int compareTo(@NotNull Entry other) { @Override Formula createFormula(String string) { - return new BaseFormula(operator, string) { + return new BaseFormula(getOperator(), string) { @NotNull - ExpressionBuilder builder() { - return new DoubleExpressionBuilder(); + Builder builder() { + return new DoubleBuilder(); } }; } diff --git a/src/main/java/com/bitaspire/cyberlevels/Message.java b/src/main/java/com/bitaspire/cyberlevels/Message.java index a6c7c25..d7cf27f 100644 --- a/src/main/java/com/bitaspire/cyberlevels/Message.java +++ b/src/main/java/com/bitaspire/cyberlevels/Message.java @@ -1,18 +1,28 @@ package com.bitaspire.cyberlevels; +import com.bitaspire.libs.common.util.ReplaceUtils; import com.bitaspire.cyberlevels.cache.Lang; import com.bitaspire.cyberlevels.user.LevelUser; +import com.bitaspire.libs.takion.message.MessageSender; import lombok.Setter; import lombok.experimental.Accessors; -import me.croabeast.beanslib.key.ValueReplacer; -import me.croabeast.beanslib.message.MessageSender; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.bukkit.entity.Player; import java.util.*; import java.util.function.Function; import java.util.function.UnaryOperator; +/** + * Fluent helper for building and sending localized plugin messages. + * + *

This utility acts as a lightweight message builder around Takion's {@link MessageSender}. It + * collects message lines, target context, placeholder replacements, and an optional post-processing + * operator before dispatching everything in one call to {@link #send()}. + * + *

The class is intentionally short-lived. Typical usage is to create a new instance, configure + * the desired target and placeholders, and then discard it after sending. + */ public final class Message { private final Lang lang = CyberLevels.instance().cache().lang(); @@ -23,14 +33,33 @@ public final class Message { private UnaryOperator operator = null; private final Map placeholders = new LinkedHashMap<>(); - private final MessageSender sender = new MessageSender().setLogger(false).setCaseSensitive(false); - + private final MessageSender sender = CyberLevels.instance() + .library().getLoadedSender() + .setLogger(false).setSensitive(false); + + /** + * Routes the message to a concrete Bukkit player and uses that same player as the parser + * context for placeholders supported by the underlying messaging library. + * + * @param player player that should receive the message + * @return the same builder instance for chaining + */ public Message player(Player player) { sender.setTargets(player); sender.setParser(player); return this; } + /** + * Resolves the live Bukkit player from a {@link LevelUser} and, when available, delegates to + * {@link #player(Player)}. + * + *

If the wrapped user cannot currently provide an online player instance, the call is simply + * ignored and the builder remains unchanged. + * + * @param user level user whose current player should receive the message + * @return the same builder instance for chaining + */ public Message player(LevelUser user) { try { player(user.getPlayer()); @@ -38,28 +67,68 @@ public Message player(LevelUser user) { return this; } + /** + * Appends multiple raw message lines to the current payload. + * + *

Null or empty lists are ignored so callers can safely pass optional content without extra + * guard clauses. + * + * @param list message lines to append + * @return the same builder instance for chaining + */ public Message list(List list) { if (list != null && !list.isEmpty()) messages.addAll(list); return this; } + /** + * Appends one or more raw message lines to the current payload. + * + * @param messages message lines to append + * @return the same builder instance for chaining + */ public Message list(String... messages) { if (messages != null) list(Arrays.asList(messages)); return this; } + /** + * Resolves message lines from the active {@link Lang} cache and appends them to the payload. + * + *

This is the most common entry point when sending configurable language messages through a + * method reference such as {@code Lang::getReloaded}. + * + * @param function resolver that extracts a list of lines from {@link Lang} + * @return the same builder instance for chaining + */ public Message list(Function> function) { - if (lang != null && function != null) - list(function.apply(lang)); + if (lang != null && function != null) list(function.apply(lang)); return this; } + /** + * Resolves a single message line from the active {@link Lang} cache and appends it to the + * payload. + * + * @param function resolver that extracts one line from {@link Lang} + * @return the same builder instance for chaining + */ public Message single(Function function) { if (lang != null && function != null) list(function.apply(lang)); return this; } + /** + * Registers a placeholder replacement that will be applied to every queued line before sending. + * + *

Keys are normalized to the plugin's brace format, so both {@code player} and + * {@code {player}} map to the same internal placeholder token. + * + * @param key placeholder name, with or without surrounding braces + * @param value replacement value to render + * @return the same builder instance for chaining + */ public Message placeholder(String key, Object value) { if (StringUtils.isNotBlank(key) && value != null) { if (!key.startsWith("{")) key = '{' + key + '}'; @@ -68,6 +137,15 @@ public Message placeholder(String key, Object value) { return this; } + /** + * Starts a placeholder key/value batch definition. + * + *

The returned helper stores the keys first and then expects a matching call to + * {@link Values#values(Object...)} so placeholders can be populated in positional order. + * + * @param strings placeholder keys that will later receive values + * @return helper object bound to this builder + */ public Values keys(String... strings) { Values values = new Values(); if (strings != null) @@ -75,30 +153,44 @@ public Values keys(String... strings) { return values; } - public boolean send(Player player) { - player(player); - return send(); - } - - public boolean send(LevelUser user) { - player(user); - return send(); - } - + /** + * Finalizes placeholder replacements, applies the optional output operator, normalizes legacy + * action-bar tags, and delegates the finished payload to Takion. + * + * @return {@code true} when at least one non-blank line was sent successfully + */ public boolean send() { messages.removeIf(Objects::isNull); - messages.replaceAll(s -> ValueReplacer.forEach(placeholders, s)); + messages.replaceAll(s -> ReplaceUtils.replaceEach(placeholders, s)); if (operator != null) messages.replaceAll(operator); + messages.replaceAll(s -> s.replace("[actionbar]", "[action-bar]")); - return sender.send(messages); + return !messages.isEmpty() && + !StringUtils.isBlank(messages.get(0)) && + sender.send(messages); } + /** + * Positional placeholder helper returned by {@link #keys(String...)}. + * + *

This helper exists purely to keep call sites compact when a message needs several + * placeholders at once. + */ public class Values { private final List keys = new ArrayList<>(); + /** + * Applies the provided values to the keys previously captured by {@link #keys(String...)}. + * + *

If the number of keys and values does not match, the builder is returned unchanged and + * no placeholders are written. + * + * @param values placeholder values in the same order as the declared keys + * @return parent {@link Message} builder for continued chaining or sending + */ public Message values(Object... values) { if (keys.isEmpty() || values == null || keys.size() != values.length) return Message.this; @@ -109,4 +201,4 @@ public Message values(Object... values) { return Message.this; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/bitaspire/cyberlevels/UserManagerImpl.java b/src/main/java/com/bitaspire/cyberlevels/UserManagerImpl.java index 8e032c1..985b974 100644 --- a/src/main/java/com/bitaspire/cyberlevels/UserManagerImpl.java +++ b/src/main/java/com/bitaspire/cyberlevels/UserManagerImpl.java @@ -7,12 +7,13 @@ import com.bitaspire.cyberlevels.user.LevelUser; import com.bitaspire.cyberlevels.user.UserManager; import lombok.Getter; -import org.apache.commons.lang.StringUtils; +import lombok.RequiredArgsConstructor; +import com.bitaspire.libs.scheduler.GlobalRunnable; +import com.bitaspire.libs.scheduler.GlobalTask; +import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; import org.jetbrains.annotations.NotNull; import java.io.BufferedReader; @@ -22,16 +23,28 @@ import java.nio.file.Path; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; final class UserManagerImpl implements UserManager { + private static final long DATABASE_SYNC_INTERVAL_TICKS = 20L; + private static final long DATABASE_SYNC_LOOKBACK_MS = 1_500L; + private static final long LOCAL_OFFLINE_CACHE_TTL_MS = 15_000L; + final CyberLevels main; final Cache cache; + private final AtomicBoolean leaderboardQueued = new AtomicBoolean(false); + private final AtomicBoolean leaderboardDirty = new AtomicBoolean(false); + private final AtomicBoolean databaseSyncInFlight = new AtomicBoolean(false); private final BaseSystem system; private final Map> users = new ConcurrentHashMap<>(); + private final Map localOfflineSnapshots = new ConcurrentHashMap<>(); + private final Map knownDatabaseUpdatedAt = new ConcurrentHashMap<>(); + private volatile long lastObservedDatabaseUpdateAt = System.currentTimeMillis(); - BukkitTask autoSaveTask = null; + GlobalTask autoSaveTask = null; + GlobalTask databaseSyncTask = null; @Getter private Database database = null; @@ -60,7 +73,6 @@ void checkMigration() { if (old == null) return; final Database now = database; - if (now != null && old.getClass().equals(now.getClass())) return; main.logger("&eDetected database type change from " + @@ -99,21 +111,37 @@ void checkMigration() { @Override public LevelUser getUser(UUID uuid) { LevelUser user = users.get(uuid); + if (user == null) { OfflinePlayer player = Bukkit.getPlayer(uuid); - if (player == null) player = Bukkit.getOfflinePlayer(uuid); - loadUser(player); - return users.get(uuid); + if (player == null) + player = Bukkit.getOfflinePlayer(uuid); + return loadUser(player); } + return user; } @Override public LevelUser getUser(String name) { + if (StringUtils.isBlank(name)) return null; + + Player online = Bukkit.getPlayerExact(name); + if (online != null) return getUser(online); + + for (Player player : Bukkit.getOnlinePlayers()) + if (player.getName().equalsIgnoreCase(name)) return getUser(player); + + for (OfflinePlayer offline : Bukkit.getOfflinePlayers()) { + String offlineName = offline.getName(); + if (offlineName != null && offlineName.equalsIgnoreCase(name)) + return getUser(offline.getUniqueId()); + } + for (LevelUser user : users.values()) try { - String n = Objects.requireNonNull(user.getName()); - if (n.equalsIgnoreCase(name)) return user; + String loadedName = Objects.requireNonNull(user.getName()); + if (loadedName.equalsIgnoreCase(name)) return user; } catch (Exception ignored) {} return null; @@ -133,26 +161,51 @@ long getRewardLevel(LevelUser user) { } } - private LevelUser loadFromFlatFile(UUID uuid) { - LevelUser user = system.createUser(uuid); + private long parseLong(String raw, long fallback, UUID uuid, String field) { + if (StringUtils.isBlank(raw)) return fallback; + + String value = raw.trim(); + try { + return Long.parseLong(value); + } catch (Exception ignored) { + main.logger("&eInvalid " + field + " value '" + value + "' for " + uuid + " in flat-file. Using " + fallback + "."); + return fallback; + } + } + + private String parseExp(String raw, UUID uuid) { + String fallback = String.valueOf(system.getStartExp()); + if (StringUtils.isBlank(raw)) return fallback; + + String value = raw.trim(); + try { + system.getOperator().valueOf(value); + return value; + } catch (Exception ignored) { + main.logger("&eInvalid exp value '" + value + "' for " + uuid + " in flat-file. Using " + fallback + "."); + return fallback; + } + } + private LevelUser loadFromFlatFile(UUID uuid) { Path file = new File(main.getDataFolder(), "player_data" + File.separator + uuid + ".clv").toPath(); if (!Files.exists(file)) return null; try (BufferedReader reader = Files.newBufferedReader(file)) { + LevelUser user = system.createUser(uuid); String line; line = reader.readLine(); if (line == null) return null; - user.setLevel(Long.parseLong(line.trim()), false); + long level = parseLong(line, system.getStartLevel(), uuid, "level"); line = reader.readLine(); if (line == null) return null; - user.setExp(line.trim(), false, false, false); + String exp = parseExp(line, uuid); line = reader.readLine(); - long claimed = (line != null) ? Long.parseLong(line.trim()) : user.getLevel(); - setRewardLevel(user, claimed); + long claimed = (line != null) ? parseLong(line, level, uuid, "highest rewarded level") : level; + system.applyStoredState(user, level, exp, claimed); return user; } catch (Exception e) { @@ -179,27 +232,21 @@ private void saveToFlatFile(LevelUser user) { } } - private void loadUser(OfflinePlayer offline) { - Player player = (offline instanceof Player) ? (Player) offline : null; - - UUID uuid = offline.getUniqueId(); - LevelUser user = users.get(uuid); - - if (user != null && player != null && !user.isOnline()) { - LevelUser newUser = system.createUser(uuid); - - newUser.setLevel(user.getLevel(), false); - newUser.setExp(user.getExp() + "", true, false, false); - setRewardLevel(newUser, getRewardLevel(user)); - - users.put(uuid, newUser); - return; - } - + private LoadResult loadUserData(UUID uuid) { + LevelUser user; String migrationMessage = ""; + long databaseUpdatedAt = 0L; if (database != null) { - user = (player != null) ? database.getUser(player) : database.getUser(uuid); + DatabaseFactory.DatabaseImpl databaseImpl = databaseImpl(); + DatabaseFactory.DatabaseImpl.StoredUserData stored = databaseImpl != null ? databaseImpl.fetchUserData(uuid) : null; + + if (stored != null) { + user = databaseImpl.toLevelUser(stored); + databaseUpdatedAt = stored.updatedAt; + } else { + user = database.getUser(uuid); + } if (user == null) { if ((user = loadFromFlatFile(uuid)) != null) { @@ -219,7 +266,7 @@ private void loadUser(OfflinePlayer offline) { if (user == null) { Database old = main.database; if (old != null) { - LevelUser oldUser = (player != null) ? old.getUser(player) : old.getUser(uuid); + LevelUser oldUser = old.getUser(uuid); if (oldUser != null) { migrationMessage = " from " + old.getClass().getSimpleName() + " to flat-file"; LevelUser copy = system.createUser(oldUser); @@ -232,44 +279,157 @@ private void loadUser(OfflinePlayer offline) { if (user == null) user = system.createUser(uuid); } - if (StringUtils.isNotBlank(migrationMessage)) - main.logger("Migrated " + (player != null ? player.getName() : uuid) + migrationMessage); + return new LoadResult(user, migrationMessage, databaseUpdatedAt); + } + + private void loadUserAsync(OfflinePlayer offline, boolean updateLeaderboard) { + Player player = (offline instanceof Player) ? (Player) offline : null; + + UUID uuid = offline.getUniqueId(); + LevelUser user = users.get(uuid); + + if (user != null && player != null && !user.isOnline()) { + if (shouldReuseLocalOfflineSnapshot(uuid)) { + LevelUser newUser = system.createUser(uuid); + system.applyStoredState(newUser, user.getLevel(), String.valueOf(user.getExp()), getRewardLevel(user)); + + users.put(uuid, newUser); + localOfflineSnapshots.remove(uuid); + if (updateLeaderboard) scheduleLeaderboardUpdate(); + return; + } - users.put(uuid, user); - system.updateLeaderboard(); + users.remove(uuid, user); + } + + main.scheduler().runTaskAsynchronously(() -> { + LoadResult result = loadUserData(uuid); + main.scheduler().runTask(() -> finishUserLoad(uuid, player, result, updateLeaderboard)); + }); + } + + private boolean shouldReuseLocalOfflineSnapshot(UUID uuid) { + if (database == null) return true; + + Long savedAt = localOfflineSnapshots.get(uuid); + return savedAt != null && System.currentTimeMillis() - savedAt <= LOCAL_OFFLINE_CACHE_TTL_MS; + } + + private LevelUser loadUser(OfflinePlayer offline) { + UUID uuid = offline.getUniqueId(); + Player player = (offline instanceof Player) ? (Player) offline : null; + LoadResult result = loadUserData(uuid); + finishUserLoad(uuid, player, result, true); + return users.getOrDefault(uuid, result.user); + } + + private LevelUser toOnlineUser(UUID uuid, LevelUser source) { + LevelUser online = system.createUser(uuid); + system.applyStoredState(online, source.getLevel(), String.valueOf(source.getExp()), getRewardLevel(source)); + return online; + } + + private void finishUserLoad(UUID uuid, Player player, LoadResult result, boolean updateLeaderboard) { + if (StringUtils.isNotBlank(result.migrationMessage)) + main.logger("Migrated " + (player != null ? player.getName() : uuid) + result.migrationMessage); + + LevelUser existing = users.get(uuid); + if (existing != null) { + if (player != null && !existing.isOnline()) + users.put(uuid, toOnlineUser(uuid, existing)); + + if (result.databaseUpdatedAt > 0L) { + knownDatabaseUpdatedAt.put(uuid, result.databaseUpdatedAt); + lastObservedDatabaseUpdateAt = Math.max(lastObservedDatabaseUpdateAt, result.databaseUpdatedAt); + } + + if (player != null) localOfflineSnapshots.remove(uuid); + if (updateLeaderboard) scheduleLeaderboardUpdate(); + return; + } + + LevelUser loaded = result.user; + if (player != null && !loaded.isOnline()) loaded = toOnlineUser(uuid, loaded); + + users.put(uuid, loaded); + if (result.databaseUpdatedAt > 0L) { + knownDatabaseUpdatedAt.put(uuid, result.databaseUpdatedAt); + lastObservedDatabaseUpdateAt = Math.max(lastObservedDatabaseUpdateAt, result.databaseUpdatedAt); + } + + if (player != null) localOfflineSnapshots.remove(uuid); + if (updateLeaderboard) scheduleLeaderboardUpdate(); + } + + private void scheduleLeaderboardUpdate() { + leaderboardDirty.set(true); + if (!leaderboardQueued.compareAndSet(false, true)) return; + main.scheduler().runTask(this::drainLeaderboardUpdates); + } + + private void drainLeaderboardUpdates() { + do { + leaderboardDirty.set(false); + system.updateLeaderboard(); + } while (leaderboardDirty.get()); + + leaderboardQueued.set(false); + if (leaderboardDirty.get() && leaderboardQueued.compareAndSet(false, true)) + main.scheduler().runTask(this::drainLeaderboardUpdates); + } + + @RequiredArgsConstructor + private class LoadResult { + final LevelUser user; + final String migrationMessage; + final long databaseUpdatedAt; } @Override public void loadPlayer(OfflinePlayer offline) { - loadUser(offline); + loadUserAsync(offline, true); } @Override public void loadPlayer(Player player) { - loadUser(player); + loadUserAsync(player, true); } @Override public void savePlayer(Player player, boolean clearData) { + savePlayer(player, clearData, false); + } + + void savePlayerSync(Player player, boolean clearData) { + savePlayer(player, clearData, true); + } + + private void savePlayer(Player player, boolean clearData, boolean syncSave) { LevelUser user = users.get(player.getUniqueId()); if (user == null) return; - saveUser(user); + if (syncSave) saveUserSync(user); + else saveUserAsync(user); if (!clearData) return; UUID uuid = user.getUuid(); + if (syncSave) { + users.remove(uuid); + localOfflineSnapshots.remove(uuid); + return; + } + try { LevelUser offline = system.createOffline(uuid); - offline.setLevel(user.getLevel(), false); - offline.setExp(user.getExp(), false, false, false); - - setRewardLevel(offline, getRewardLevel(user)); + system.applyStoredState(offline, user.getLevel(), String.valueOf(user.getExp()), getRewardLevel(user)); users.put(uuid, offline); + localOfflineSnapshots.put(uuid, System.currentTimeMillis()); } catch (Exception e) { users.remove(uuid); + localOfflineSnapshots.remove(uuid); main.logger("&cNot able to convert to OfflineUser for: " + user.getName() + ". Deleting cache..."); e.printStackTrace(); } @@ -278,15 +438,31 @@ public void savePlayer(Player player, boolean clearData) { @Override public void saveUser(LevelUser user) { if (database != null) { + knownDatabaseUpdatedAt.put(user.getUuid(), System.currentTimeMillis()); database.updateUser(user); return; } saveToFlatFile(user); } + private void saveUserSync(LevelUser user) { + if (database != null) { + knownDatabaseUpdatedAt.put(user.getUuid(), System.currentTimeMillis()); + database.updateUserSync(user); + return; + } + saveToFlatFile(user); + } + + private void saveUserAsync(LevelUser user) { + main.scheduler().runTaskAsynchronously(() -> saveUser(user)); + } + @Override public void removeUser(UUID uuid) { users.remove(uuid); + localOfflineSnapshots.remove(uuid); + knownDatabaseUpdatedAt.remove(uuid); if (database != null) { database.removeUser(uuid); @@ -305,18 +481,36 @@ void loadOfflinePlayers() { long l = System.currentTimeMillis(); main.logger("&dLoading data for offline players..."); - int counter = 0; - for (OfflinePlayer player : Bukkit.getOfflinePlayers()) { - loadPlayer(player); - counter++; - } + OfflinePlayer[] players = Bukkit.getOfflinePlayers(); + if (players.length < 1) return; - if (counter < 1) return; + int batchSize = 10; - main.logger("&7Loaded data for &e" + counter + - " &7offline player(s) in &a" + - (System.currentTimeMillis() - l) + - "ms&7.", ""); + new GlobalRunnable(main.scheduler()) { + int index = 0; + int loaded = 0; + + @Override + public void run() { + int processed = 0; + while (index < players.length && processed < batchSize) { + loadUserAsync(players[index++], false); + loaded++; + processed++; + } + + if (index < players.length) return; + + cancel(); + if (loaded > 0) + main.logger("&7Loaded data for &e" + loaded + + " &7offline player(s) in &a" + + (System.currentTimeMillis() - l) + + "ms&7.", ""); + + scheduleLeaderboardUpdate(); + } + }.runTaskTimer(0L, 1L); } @Override @@ -328,7 +522,7 @@ public void loadOnlinePlayers() { int counter = 0; for (Player player : Bukkit.getOnlinePlayers()) { - loadPlayer(player); + loadUserAsync(player, false); counter++; } @@ -336,13 +530,90 @@ public void loadOnlinePlayers() { main.logger("&7Loaded data for &e" + counter + " &7online player(s) in &a" + - (System.currentTimeMillis() - l) + - "ms&7.", ""); + (System.currentTimeMillis() - l) + "ms&7.", ""); + scheduleLeaderboardUpdate(); } @Override public void saveOnlinePlayers(boolean clearData) { - Bukkit.getOnlinePlayers().forEach(p -> savePlayer(p, clearData)); + Bukkit.getOnlinePlayers().forEach(p -> savePlayer(p, clearData, false)); + } + + void saveOnlinePlayersSync() { + Bukkit.getOnlinePlayers().forEach(p -> savePlayer(p, true, true)); + } + + private DatabaseFactory.DatabaseImpl databaseImpl() { + return (DatabaseFactory.DatabaseImpl) database; + } + + void startDatabaseSync() { + if (databaseSyncTask != null || databaseImpl() == null) return; + scheduleDatabaseSync(); + } + + private void scheduleDatabaseSync() { + databaseSyncTask = main.scheduler().runTaskLaterAsynchronously(() -> { + try { + pollDatabaseUpdates(); + } finally { + if (main.isEnabled() && databaseSyncTask != null) + scheduleDatabaseSync(); + } + }, DATABASE_SYNC_INTERVAL_TICKS); + } + + private void pollDatabaseUpdates() { + DatabaseFactory.DatabaseImpl databaseImpl = databaseImpl(); + if (databaseImpl == null || users.isEmpty()) return; + if (!databaseSyncInFlight.compareAndSet(false, true)) return; + + try { + long queryAfter = Math.max(0L, lastObservedDatabaseUpdateAt - DATABASE_SYNC_LOOKBACK_MS); + List updates = + databaseImpl.getUsersUpdatedSince(queryAfter); + + if (updates.isEmpty()) return; + + List relevant = new ArrayList<>(); + long watermark = lastObservedDatabaseUpdateAt; + + for (DatabaseFactory.DatabaseImpl.StoredUserData update : updates) { + watermark = Math.max(watermark, update.updatedAt); + + if (!users.containsKey(update.uuid)) continue; + + long known = knownDatabaseUpdatedAt.getOrDefault(update.uuid, 0L); + if (update.updatedAt > known) + relevant.add(update); + } + + lastObservedDatabaseUpdateAt = watermark; + if (relevant.isEmpty()) return; + + main.scheduler().runTask(() -> applyDatabaseUpdates(relevant)); + } finally { + databaseSyncInFlight.set(false); + } + } + + private void applyDatabaseUpdates(List updates) { + boolean changed = false; + + for (DatabaseFactory.DatabaseImpl.StoredUserData update : updates) { + LevelUser user = users.get(update.uuid); + if (user == null) continue; + + long known = knownDatabaseUpdatedAt.getOrDefault(update.uuid, 0L); + if (update.updatedAt <= known) continue; + + system.applyStoredState(user, update.level, update.exp, update.highestRewarded); + knownDatabaseUpdatedAt.put(update.uuid, update.updatedAt); + localOfflineSnapshots.remove(update.uuid); + changed = true; + } + + if (changed) scheduleLeaderboardUpdate(); } @Override @@ -350,30 +621,33 @@ public void startAutoSave() { if (!cache.config().isAutoSaveEnabled()) return; Config config = cache.config(); - autoSaveTask = (new BukkitRunnable() { - @Override - public void run() { - long start = System.currentTimeMillis(); - main.userManager().saveOnlinePlayers(false); + autoSaveTask = main.scheduler().runTaskLater(() -> { + long start = System.currentTimeMillis(); + main.userManager().saveOnlinePlayers(false); - if (config.syncLeaderboardOnAutoSave()) - system.getLeaderboard().update(); + if (config.syncLeaderboardOnAutoSave()) + system.getLeaderboard().update(); - if (config.isMessagesOnAutoSave()) - cache.lang().sendMessage( - null, Lang::getAutoSave, "ms", - System.currentTimeMillis() - start - ); + if (config.isMessagesOnAutoSave()) + cache.lang().sendMessage( + null, Lang::getAutoSave, "ms", + System.currentTimeMillis() - start + ); - startAutoSave(); - } - }).runTaskLater(main, 20L * config.getAutoSaveInterval()); + startAutoSave(); + }, 20L * config.getAutoSaveInterval()); } @Override public void cancelAutoSave() { - if (autoSaveTask == null) return; - autoSaveTask.cancel(); - autoSaveTask = null; + if (autoSaveTask != null) { + autoSaveTask.cancel(); + autoSaveTask = null; + } + + if (databaseSyncTask != null) { + databaseSyncTask.cancel(); + databaseSyncTask = null; + } } } diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/AntiAbuse.java b/src/main/java/com/bitaspire/cyberlevels/cache/AntiAbuse.java index 1cae6de..faad0e1 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/AntiAbuse.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/AntiAbuse.java @@ -4,7 +4,7 @@ import com.bitaspire.cyberlevels.level.ExpSource; import lombok.Getter; import lombok.experimental.Accessors; -import me.croabeast.file.Configurable; +import com.bitaspire.libs.file.Configurable; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; @@ -13,6 +13,13 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; +/** + * Cache and runtime controller for the anti-abuse configuration. + * + *

This component loads every anti-abuse module defined in {@code anti-abuse.yml}, exposes them + * as the public {@link com.bitaspire.cyberlevels.level.AntiAbuse} API, and manages the lifecycle + * of their scheduled limiter reset timers during plugin startup and shutdown. + */ @Getter public class AntiAbuse { @@ -49,17 +56,34 @@ public class AntiAbuse { catch (IOException ignored) {} } + /** + * Returns a defensive copy of the loaded anti-abuse modules keyed by their configuration id. + * + * @return snapshot of loaded anti-abuse modules + */ @NotNull public Map getAntiAbuses() { return new HashMap<>(modules); } + /** + * Starts every configured limiter timer. + * + *

This is typically called once after the plugin runtime finishes loading so timed limiter + * resets can begin running in the background. + */ public void register() { for (Module module : modules.values()) { module.getTimer().start(); } } + /** + * Stops all anti-abuse timers and clears every tracked cooldown/limiter state. + * + *

This is used during plugin shutdown and reload so transient anti-abuse state does not leak + * from an old runtime into a freshly loaded one. + */ public void unregister() { for (Module module : modules.values()) { module.cancelTimer(); diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/BlockExpKeys.java b/src/main/java/com/bitaspire/cyberlevels/cache/BlockExpKeys.java new file mode 100644 index 0000000..b7fc140 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/cache/BlockExpKeys.java @@ -0,0 +1,95 @@ +package com.bitaspire.cyberlevels.cache; + +import java.util.Locale; +import org.bukkit.block.Block; +import org.jetbrains.annotations.NotNull; + +/** + * Utility methods for building normalized earn-exp lookup keys for blocks. + * + *

CyberLevels can reward different amounts of EXP for the same material depending on extra + * state, most notably crop age. This helper centralizes the conversion from Bukkit block data to + * the normalized string keys stored in the earn-exp configuration, so matching remains consistent + * across runtime checks, cache loading, and version-specific compatibility branches. + */ +public final class BlockExpKeys { + + private BlockExpKeys() {} + + /** + * Extracts the material portion of a normalized specific key. + * + *

This is useful when code needs to compare a fully qualified key such as + * {@code WHEAT[AGE=7]} against a more general fallback key such as {@code WHEAT}. + * + * @param key normalized key such as {@code WHEAT} or {@code WHEAT[AGE=7]} + * @return base material token without any state suffix + */ + @NotNull + public static String baseMaterialKey(@NotNull String key) { + int i = key.indexOf('['); + return i < 0 ? key : key.substring(0, i); + } + + /** + * Normalizes a config or runtime key into the canonical format used by the earn-exp cache. + * + * @param key raw key taken from config or from a runtime block lookup + * @return trimmed, uppercased key suitable for map lookups + */ + @NotNull + public static String normalizeSpecificKey(@NotNull String key) { + return key.trim().toUpperCase(Locale.ENGLISH); + } + + /** + * Builds the earn-exp lookup key for a live Bukkit block. + * + *

Ageable crops produce a more specific key in the form {@code MATERIAL[AGE=n]} so + * configurations can distinguish mature crops from immature ones. Non-ageable blocks fall back + * to the plain material name. + * + * @param block Bukkit block to inspect + * @param serverVersion server version reported by + * {@link com.bitaspire.cyberlevels.CyberLevels#serverVersion()} + * @return normalized key suitable for EXP source matching + */ + @NotNull + public static String blockKey(@NotNull Block block, double serverVersion) { + String mat = block.getType().toString(); + if (serverVersion > 12) { + if (block.getBlockData() instanceof org.bukkit.block.data.Ageable) { + org.bukkit.block.data.Ageable ageable = + (org.bukkit.block.data.Ageable) block.getBlockData(); + return normalizeSpecificKey(mat + "[age=" + ageable.getAge() + "]"); + } + } else { + Integer legacyAge = legacyMaterialAge(block); + if (legacyAge != null) { + return normalizeSpecificKey(mat + "[age=" + legacyAge + "]"); + } + } + return normalizeSpecificKey(mat); + } + + /** + * Attempts to read crop age from legacy pre-1.13 material data without introducing a hard + * compile-time dependency on classes that may be absent from newer APIs. + * + * @param block block whose legacy state should be inspected + * @return detected age value, or {@code null} when the block is not ageable in the legacy API + */ + private static Integer legacyMaterialAge(@NotNull Block block) { + try { + Class ageableClass = Class.forName("org.bukkit.material.Ageable"); + Object data = block.getState().getData(); + if (ageableClass.isInstance(data)) { + Object age = ageableClass.getMethod("getAge").invoke(data); + if (age instanceof Number) { + return ((Number) age).intValue(); + } + } + } catch (Throwable ignored) {} + return null; + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/CLVFile.java b/src/main/java/com/bitaspire/cyberlevels/cache/CLVFile.java index f26a8b5..3db410a 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/CLVFile.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/CLVFile.java @@ -1,7 +1,7 @@ package com.bitaspire.cyberlevels.cache; import com.bitaspire.cyberlevels.CyberLevels; -import me.croabeast.file.ConfigurableFile; +import com.bitaspire.libs.file.ConfigurableFile; import java.io.IOException; diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/Cache.java b/src/main/java/com/bitaspire/cyberlevels/cache/Cache.java index f3970a4..636de77 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/Cache.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/Cache.java @@ -5,6 +5,15 @@ import lombok.Getter; import lombok.experimental.Accessors; +/** + * Aggregates the plugin configuration caches loaded from disk. + * + *

The cache is split into two stages. Core files such as {@code config.yml}, {@code lang.yml}, + * {@code levels.yml}, and {@code rewards.yml} are loaded in the constructor because other startup + * systems depend on them immediately. Heavier secondary files such as anti-abuse and earn-exp are + * loaded later through {@link #loadSecondaryFiles()} once the numeric engine and user manager have + * already been prepared. + */ @Accessors(fluent = true) @Getter public class Cache { @@ -20,6 +29,11 @@ public class Cache { private AntiAbuse antiAbuse; private EarnExp earnExp; + /** + * Loads the primary configuration caches required for the initial plugin bootstrap. + * + * @param main owning plugin instance + */ public Cache(CyberLevels main) { this.main = main; @@ -40,6 +54,13 @@ public Cache(CyberLevels main) { main.logger("&7Loaded &e4 &7main files in &a" + (System.currentTimeMillis() - start) + "ms&7.", ""); } + /** + * Loads the secondary caches that depend on the already-initialized runtime. + * + *

This stage creates anti-abuse and earn-exp data after the main cache, level system, and + * user services are available, which avoids partially initialized state during startup or + * reloads. + */ public void loadSecondaryFiles() { antiAbuse = new AntiAbuse(main); diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/Config.java b/src/main/java/com/bitaspire/cyberlevels/cache/Config.java index 23d94d2..399021b 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/Config.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/Config.java @@ -5,6 +5,14 @@ import lombok.experimental.Accessors; import org.bukkit.configuration.ConfigurationSection; +/** + * In-memory view of the plugin's main configuration file. + * + *

This type exposes the normalized settings that influence persistence, rounding, leaderboard + * behaviour, automatic updates, chat output, and several quality-of-life toggles used across the + * runtime. Values are read once on load and can be refreshed from disk through {@link #update()} + * when the configuration file itself is auto-updated. + */ @Getter public class Config { @@ -24,6 +32,7 @@ public class Config { private boolean expIntegerOnly = false; private boolean leaderboardEnabled = true; + private int leaderboardMaxPositions = 10; @Accessors(fluent = true) private boolean syncLeaderboardOnAutoSave = true, leaderboardInstantUpdate = false; @@ -31,6 +40,8 @@ public class Config { private boolean autoSaveEnabled = true; private int autoSaveInterval = 300; + private boolean tabCompleteLoadOfflineUsers = true; + @Accessors(fluent = true) private boolean preventDuplicateRewards = false, stackComboExp = true, @@ -44,6 +55,10 @@ public class Config { autoUpdateLang = true, autoUpdateEarnExp = true; + private boolean spigotUpdateCheckEnabled = true; + @Accessors(fluent = true) + private boolean spigotUpdateCheckNotifyOpsChat = true; + private boolean messagesOnAutoSave = true; private boolean messagesOnConsole = true; @@ -59,6 +74,8 @@ public class Config { expIntegerOnly = file.get("config.earn-exp.integer-only", false); leaderboardEnabled = file.get("config.leaderboard.enabled", true); + leaderboardMaxPositions = clampLeaderboardPositions( + file.get("config.leaderboard.max-positions", leaderboardMaxPositions)); syncLeaderboardOnAutoSave = file.get("config.leaderboard.sync-on-auto-save", true); leaderboardInstantUpdate = file.get("config.leaderboard.instant-update", false); @@ -69,6 +86,8 @@ public class Config { autoSaveEnabled = file.get("config.auto-save.enabled", true); autoSaveInterval = file.get("config.auto-save.interval", autoSaveInterval); + tabCompleteLoadOfflineUsers = file.get("config.tab-complete.load-offline-users", tabCompleteLoadOfflineUsers); + multiplierCommands = file.get("config.multiplier.commands", false); multiplierEvents = file.get("config.multiplier.events", true); @@ -76,16 +95,37 @@ public class Config { autoUpdateLang = file.get("config.auto-update.lang", true); autoUpdateEarnExp = file.get("config.auto-update.earn-exp", true); + spigotUpdateCheckEnabled = file.get("config.spigot-update-check.enabled", true); + spigotUpdateCheckNotifyOpsChat = file.get("config.spigot-update-check.notify-ops-chat", true); + messagesOnAutoSave = file.get("config.messages.auto-save", true); messagesOnConsole = file.get("config.messages.message-console", true); } catch (Exception ignored) {} } + private static int clampLeaderboardPositions(int value) { + if (value < 1) return 1; + return Math.min(value, 1000); + } + + /** + * Persists any automatic schema or comment updates supported by the backing {@link CLVFile}. + * + *

This does not re-read values into the current instance. It only delegates the update + * operation to the configuration wrapper so the on-disk file can receive missing keys or + * template changes. + */ public void update() { if (file != null) file.update(); } + /** + * Parsed database subsection from {@code config.yml}. + * + *

This nested type stores every connection parameter required by CyberLevels to decide + * whether it should run with flat-file persistence, SQLite, or an external SQL database. + */ @Accessors(fluent = false) @Getter public static class Database { @@ -95,7 +135,8 @@ public static class Database { database = "database", username = "username", password = "password", table = "levels", type = "MySQL", - sqliteFile = "plugins/CyberLevels/data.db"; + sqliteFile = "plugins/CyberLevels/data.db", + h2File = "plugins/CyberLevels/data.h2"; Database(ConfigurationSection section) { if (section == null) return; @@ -111,6 +152,7 @@ public static class Database { table = section.getString("table", table); sqliteFile = section.getString("sqlite-file", sqliteFile); + h2File = section.getString("h2-file", h2File); type = section.getString("type", type); } diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/EarnExp.java b/src/main/java/com/bitaspire/cyberlevels/cache/EarnExp.java index 25911ac..00b90d3 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/EarnExp.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/EarnExp.java @@ -7,7 +7,8 @@ import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; -import org.apache.commons.lang.StringUtils; +import com.bitaspire.libs.scheduler.GlobalTask; +import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.block.Block; @@ -29,15 +30,22 @@ import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.metadata.FixedMetadataValue; import org.bukkit.potion.PotionType; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.*; import java.util.function.Function; import java.util.function.Predicate; +/** + * Runtime registry of configurable EXP sources defined in {@code earn-exp.yml}. + * + *

This cache parses the earn-exp configuration into source descriptors and then wires the + * matching Bukkit listeners or scheduled tasks required to award or remove EXP during gameplay. + * Each source can operate as a general range, a specific-value map, or a permission-based source + * depending on how it is defined in the configuration. + */ public class EarnExp { private static final Random random = new Random(); @@ -75,6 +83,10 @@ public class EarnExp { setDefaultEvents(); } + /** + * Persists automatic updates for the backing earn-exp file when supported by the configuration + * wrapper. + */ public void update() { if (file != null) file.update(); } @@ -82,20 +94,19 @@ public void update() { void setDefaultEvents() { final SourceImpl source = events.get("timed-giving"); source.setRegistrable(new ExpSource.Registrable() { - private BukkitTask task = null; + private GlobalTask task = null; @Override public void register() { if (!source.isEnabled() && !source.useSpecifics()) return; - task = Bukkit.getScheduler().runTaskTimer( - main, + long delay = 20L * Math.max(1, source.getInterval()); + task = main.scheduler().runTaskTimer( () -> { for (Player p : Bukkit.getOnlinePlayers()) sendPermissionExp(p, source); - }, 0L, - 20L * Math.max(1, source.getInterval())); + }, delay, delay); } @Override @@ -147,7 +158,8 @@ private void onPlacing(BlockPlaceEvent event) { if (main.cache().antiAbuse().onlyNaturalBlocks()) event.getBlock().setMetadata("CLV_PLACED", new FixedMetadataValue(main, true)); - sendExp(event.getPlayer(), s, event.getBlock().getType().toString()); + sendExp(event.getPlayer(), s, + BlockExpKeys.blockKey(event.getBlock(), main.serverVersion())); } }); @@ -186,7 +198,7 @@ private void onBreaking(BlockBreakEvent event) { } } - sendExp(event.getPlayer(), s, block.getType().toString()); + sendExp(event.getPlayer(), s, BlockExpKeys.blockKey(block, version)); } }); @@ -254,39 +266,36 @@ private void onBrewing(BrewEvent event) { prePotion[i] = meta.getBasePotionData().getType(); } - (new BukkitRunnable() { - @Override - public void run() { - double counter = 0; + main.scheduler().runTaskLater(() -> { + double counter = 0; - for (int i = 0; i <= 2 ; i++) { - ItemStack stack = event.getContents().getItem(i); - if (stack == null) continue; + for (int i = 0; i <= 2 ; i++) { + ItemStack stack = event.getContents().getItem(i); + if (stack == null) continue; - PotionMeta meta = (PotionMeta) stack.getItemMeta(); - if (meta == null) continue; + PotionMeta meta = (PotionMeta) stack.getItemMeta(); + if (meta == null) continue; - String data = ""; + String data = ""; - PotionType type = meta.getBasePotionData().getType(); - if (prePotion[i] == null || type != prePotion[i]) - data = type.toString(); + PotionType type = meta.getBasePotionData().getType(); + if (prePotion[i] == null || type != prePotion[i]) + data = type.toString(); - if (main.levelSystem().checkAntiAbuse(player, s)) return; - if (s.isEnabled() || s.useSpecifics()) - counter += s.getPartialMatchesExp(data); - } - - LevelUser user = main.userManager().getUser(player); + if (main.levelSystem().checkAntiAbuse(player, s)) return; + if (s.isEnabled() || s.useSpecifics()) + counter += s.getPartialMatchesExp(data); + } - if (counter > 0) { - user.addExp(counter + "", main.cache().config().isMultiplierEvents()); - return; - } + LevelUser user = main.userManager().getUser(player); - if (counter < 0) user.removeExp(Math.abs(counter) + ""); + if (counter > 0) { + user.addExp(counter, main.cache().config().isMultiplierEvents()); + return; } - }).runTaskLater(main, 1L); + + if (counter < 0) user.removeExp(Math.abs(counter)); + }, 1L); } }); @@ -311,11 +320,11 @@ private void onEnchant(EnchantItemEvent event) { LevelUser user = main.userManager().getUser(event.getEnchanter()); if (counter > 0) { - user.addExp(counter + "", main.cache().config().isMultiplierEvents()); + user.addExp(counter, main.cache().config().isMultiplierEvents()); return; } - if (counter < 0) user.removeExp(Math.abs(counter) + ""); + if (counter < 0) user.removeExp(Math.abs(counter)); } }); @@ -360,15 +369,15 @@ private void onChat(AsyncPlayerChatEvent event) { counter += s.getPartialMatchesExp(item); final double finalCounter = counter; - Bukkit.getScheduler().runTask(main, () -> { + main.scheduler().runTask(() -> { LevelUser user = main.userManager().getUser(player); if (finalCounter > 0) { - user.addExp(finalCounter + "", main.cache().config().isMultiplierEvents()); + user.addExp(finalCounter, main.cache().config().isMultiplierEvents()); return; } - if (finalCounter < 0) user.removeExp(Math.abs(finalCounter) + ""); + if (finalCounter < 0) user.removeExp(Math.abs(finalCounter)); }); } }); @@ -386,7 +395,10 @@ private Listener createDamageListener(Predicate filter, ExpSource source return new Listener() { @EventHandler(priority = EventPriority.HIGHEST) public void onDamage(EntityDamageByEntityEvent event) { - if (!source.isEnabled() || !filter.test(event.getEntity()) || event.isCancelled()) return; + if ((!source.isEnabled() && !source.useSpecifics()) || + !filter.test(event.getEntity()) || + event.isCancelled()) + return; Entity attacker = event.getDamager(); if ((attacker instanceof Projectile) && (((Projectile) attacker).getShooter() instanceof Player)) @@ -409,7 +421,9 @@ private Listener createDeathListener(Predicate filter, ExpSource source) return new Listener() { @EventHandler(priority = EventPriority.HIGHEST) public void onDeath(EntityDeathEvent event) { - if (!source.isEnabled() || !filter.test(event.getEntity())) return; + if ((!source.isEnabled() && !source.useSpecifics()) || + !filter.test(event.getEntity())) + return; EntityDamageEvent cause = event.getEntity().getLastDamageCause(); if (!(cause instanceof EntityDamageByEntityEvent)) return; @@ -434,23 +448,25 @@ public void onDeath(EntityDeathEvent event) { void sendExp(Player player, ExpSource source, String value) { if (main.levelSystem().checkAntiAbuse(player, source)) return; double counter = 0; + String matched = source.useSpecifics() ? source.matchSpecificKey(value) : null; - if (source.useSpecifics()) { - if (source.isInList(value, true)) counter = source.getSpecificRange(value).getRandom(); - } - else if (source.isEnabled()) { - if (source.isInList(value)) counter = source.getRange().getRandom(); - } + if (source.isEnabled() && + source.isInList(value) && + (matched == null || source.stackSpecificsWithGeneral())) + counter += source.getRange().getRandom(); + + if (matched != null) + counter += source.getSpecificRange(matched).getRandom(); if (counter == 0) return; LevelUser user = main.userManager().getUser(player); if (counter > 0) { - user.addExp(counter + "", main.cache().config().isMultiplierEvents()); + user.addExp(counter, main.cache().config().isMultiplierEvents()); return; } - user.removeExp(Math.abs(counter) + ""); + user.removeExp(Math.abs(counter)); } void sendPermissionExp(Player player, ExpSource source) { @@ -458,40 +474,75 @@ void sendPermissionExp(Player player, ExpSource source) { return; double counter = 0; + boolean hasSpecific = source.useSpecifics() && source.hasPermission(player, true); - if (source.useSpecifics()) { - if (source.hasPermission(player, true)) - for (String s : source.getSpecificList()) { - if (!player.hasPermission(s)) continue; - counter += source.getSpecificRange(s).getRandom(); - } - } - else if (source.isEnabled()) { - if (source.hasPermission(player)) counter = source.getRange().getRandom(); - } + if (source.isEnabled() && + source.hasPermission(player) && + (!hasSpecific || source.stackSpecificsWithGeneral())) + counter += source.getRange().getRandom(); + + if (hasSpecific) + for (String s : source.getSpecificList()) { + if (!player.hasPermission(s)) continue; + counter += source.getSpecificRange(s).getRandom(); + } if (counter == 0) return; LevelUser user = main.userManager().getUser(player); if (counter > 0) { - user.addExp(counter + "", main.cache().config().isMultiplierEvents()); + user.addExp(counter, main.cache().config().isMultiplierEvents()); return; } - user.removeExp(Math.abs(counter) + ""); + user.removeExp(Math.abs(counter)); } + /** + * Returns a defensive copy of the currently loaded EXP sources. + * + * @return EXP sources keyed by their configuration category + */ @NotNull public Map getExpSources() { return new HashMap<>(events); } + /** + * Registers every active EXP source with Bukkit or with the scheduler. + * + *

Sources that are disabled in configuration are skipped so they do not consume runtime + * listeners or tasks. + */ public void register() { - events.values().forEach(e -> e.getRegistrable().register()); + events.values().forEach(source -> { + if (source.isActive()) source.getRegistrable().register(); + }); } + /** + * Unregisters every active EXP source from Bukkit and cancels any scheduler-backed source. + * + *

This is part of the safe reload/shutdown path and ensures listeners do not remain attached + * after the runtime is rebuilt. + */ public void unregister() { - events.values().forEach(e -> e.getRegistrable().unregister()); + events.values().forEach(source -> { + if (source.isActive()) source.getRegistrable().unregister(); + }); + } + + private static String normalizeEntryKey(String specificName, String raw) { + String key = raw.trim(); + switch (specificName) { + case "blocks": + return BlockExpKeys.normalizeSpecificKey(key); + case "players": + case "permissions": + return key; + default: + return key.toUpperCase(Locale.ENGLISH); + } } @Getter @@ -511,6 +562,7 @@ class SourceImpl implements ExpSource { private final List list; private final boolean specific; + private final boolean stackSpecificsWithGeneral; @Getter(AccessLevel.NONE) private final Map specifics = new HashMap<>(); @@ -531,11 +583,12 @@ class SourceImpl implements ExpSource { list = getList("general.includes.list"); specific = get("specific-" + specificName + ".enabled", false); + stackSpecificsWithGeneral = get("specific-" + specificName + ".stack-with-general", true); if (specific) { for (String key : getList("specific-" + specificName + "." + specificName)) { String[] array = key.split(":", 2); - key = array[0].trim(); + key = normalizeEntryKey(specificName, array[0]); String value = array[1].trim(); specifics.put(key, new RangeImpl(null, value)); @@ -559,6 +612,10 @@ public void unregister() { events.put(category, this); } + boolean isActive() { + return isEnabled() || useSpecifics(); + } + T get(String path, T def) { return file.get("earn-exp." + category + "." + path, def); } @@ -581,16 +638,65 @@ public boolean useSpecifics() { return specific; } + @Override + public boolean stackSpecificsWithGeneral() { + return stackSpecificsWithGeneral; + } + @NotNull public List getSpecificList() { return new ArrayList<>(specifics.keySet()); } + private boolean includeListMatches(String value) { + String upper = value.toUpperCase(Locale.ENGLISH); + String base = BlockExpKeys.baseMaterialKey(value).toUpperCase(Locale.ENGLISH); + for (String s : list) { + if (s == null) continue; + + String entry = s.toUpperCase(Locale.ENGLISH); + if (entry.equals(upper) || entry.equals(base)) + return true; + } + + return false; + } + + @Override + @Nullable + public String matchSpecificKey(String value) { + if (!specific || specifics.isEmpty() || value == null) + return null; + + if ("blocks".equals(name)) { + String normalized = BlockExpKeys.normalizeSpecificKey(value); + if (specifics.containsKey(normalized)) + return normalized; + + String base = BlockExpKeys.baseMaterialKey(normalized); + if (!base.equals(normalized) && specifics.containsKey(base)) + return base; + + return null; + } + + if (specifics.containsKey(value)) + return value; + + if (!"players".equals(name) && !"permissions".equals(name)) { + String upper = value.toUpperCase(Locale.ENGLISH); + if (specifics.containsKey(upper)) + return upper; + } + + return null; + } + @Override public boolean isInList(String value, boolean specific) { return !specific ? - (!includes || whitelist == list.contains(value.toUpperCase())) : - (this.specific && specifics.containsKey(value)); + (!includes || whitelist == includeListMatches(value)) : + (this.specific && matchSpecificKey(value) != null); } @Override @@ -613,29 +719,32 @@ public Range getSpecificRange(String value) { public double getPartialMatchesExp(String string) { String upper = string.toUpperCase(Locale.ENGLISH); - if (!enabled) return 0.0; + double amount = 0.0; + boolean matchedSpecific = false; if (specific && !specifics.isEmpty()) { - double amount = 0.0; - - for (String s : specifics.keySet()) - if (upper.contains(s.toUpperCase(Locale.ENGLISH))) + for (String s : specifics.keySet()) { + if (upper.contains(s.toUpperCase(Locale.ENGLISH))) { amount += getSpecificRange(s).getRandom(); + matchedSpecific = true; + } + } + } + if (!enabled || (matchedSpecific && !stackSpecificsWithGeneral)) return amount; - } - if (includes) return getRange().getRandom(); + if (!includes) + return amount + getRange().getRandom(); boolean giveExp = true; - double amount = 0.0; for (String s : list) { if (!upper.contains(s.toUpperCase(Locale.ENGLISH))) continue; if (whitelist) - return getRange().getRandom(); + return amount + getRange().getRandom(); giveExp = false; break; @@ -659,6 +768,7 @@ public String toString() { ", whitelist=" + whitelist + ", list=" + list + ", specific=" + specific + + ", stackSpecificsWithGeneral=" + stackSpecificsWithGeneral + ", specifics=" + specifics + '}'; } diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/Lang.java b/src/main/java/com/bitaspire/cyberlevels/cache/Lang.java index 33ddd32..0f77dc7 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/Lang.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/Lang.java @@ -2,46 +2,57 @@ import com.bitaspire.cyberlevels.CyberLevels; import com.bitaspire.cyberlevels.Message; +import com.bitaspire.libs.file.Configurable; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; import lombok.AccessLevel; import lombok.Getter; import lombok.experimental.Accessors; -import me.croabeast.file.Configurable; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; -import java.io.IOException; -import java.util.*; -import java.util.function.Function; - +/** + * In-memory representation of {@code lang.yml}. + * + *

This cache exposes every configurable message used by CyberLevels, including command output, + * level progress views, reward broadcasts, and update-check notifications. The methods in this + * class intentionally work with message lists because Takion can deliver multi-line chat, action + * bar, and console output from the same source structure. + */ @Getter public class Lang { private CLVFile file; private String prefix = "&d&lCyber&f&lLevels &8»&r"; - private List noPermission = Collections.singletonList("&cYou don't have permission to do that!"); + private List noPermission = Collections.singletonList( + "&cYou don't have permission to do that!" + ); private List helpPlayer = Arrays.asList( - "[C] &8&m―――――――&8<&d&l Cyber&f&lLevels &8>&8&m―――――――", - " &8➼ &d/clv about &fAbout the plugin.", - " &8➼ &d/clv help &fSee the help menu.", - " &8➼ &d/clv info &fSee level progress.", - "[C] &8&m――――――――――――――――――――――――――――――――" + "[C] &8&m―――――――&8<&d&l Cyber&f&lLevels &8>&8&m―――――――", + " &8➼ &d/clv about &fAbout the plugin.", + " &8➼ &d/clv help &fSee the help menu.", + " &8➼ &d/clv info &fSee level progress.", + "[C] &8&m――――――――――――――――――――――――――――――――" ); private List helpAdmin = Arrays.asList( - "[C] &8&m―――――――&8<&d&l Cyber&f&lLevels &8>&8&m―――――――", - " &8➼ &d/clv about &fAbout the plugin.", - " &8➼ &d/clv help &fSee the help menu.", - " &8➼ &d/clv reload &fReload the plugin.", - " &8➼ &d/clv info [] &fSee level progress.", - " &8➼ &d/clv addEXP [] &fIncrease a player's EXP balance.", - " &8➼ &d/clv setEXP [] &fSet a player's EXP balance.", - " &8➼ &d/clv removeEXP [] &fDecrease a player's EXP balance.", - " &8➼ &d/clv addLevel [] &fIncrease a player's level.", - " &8➼ &d/clv setLevel [] &fSet a player's level.", - " &8➼ &d/clv removeLevel [] &fDecrease a player's level.", - "[C] &8&m――――――――――――――――――――――――――――――――" + "[C] &8&m―――――――&8<&d&l Cyber&f&lLevels &8>&8&m―――――――", + " &8➼ &d/clv about &fAbout the plugin.", + " &8➼ &d/clv help &fSee the help menu.", + " &8➼ &d/clv reload &fReload the plugin.", + " &8➼ &d/clv info [] &fSee level progress.", + " &8➼ &d/clv addEXP [] &fIncrease a player's EXP balance.", + " &8➼ &d/clv setEXP [] &fSet a player's EXP balance.", + " &8➼ &d/clv removeEXP [] &fDecrease a player's EXP balance.", + " &8➼ &d/clv addLevel [] &fIncrease a player's level.", + " &8➼ &d/clv setLevel [] &fSet a player's level.", + " &8➼ &d/clv removeLevel [] &fDecrease a player's level.", + "[C] &8&m――――――――――――――――――――――――――――――――" ); private String progressBar = "▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬"; @@ -51,37 +62,80 @@ public class Lang { private List reloading = Collections.singletonList("&7Reloading..."); private List reloaded = Collections.singletonList("&aReloaded!"); - private List autoSave = Collections.singletonList("&aSuccessfully auto-saved all player data (&7{ms}ms&a)."); + private List autoSave = Collections.singletonList( + "&aSuccessfully auto-saved all player data (&7{ms}ms&a)." + ); - private List addedExp = Collections.singletonList("&aAdded {addedEXP} to {player}'s experience. They are now level {level} with {playerEXP} experience."); - private List setExp = Collections.singletonList("&aSet {player}'s experience to {setEXP}. They are now level {level} with {playerEXP} experience."); - private List removedExp = Collections.singletonList("&aRemoved {removedEXP} from {player}'s experience. They are now level {level} with {playerEXP} experience."); + private List addedExp = Collections.singletonList( + "&aAdded {addedEXP} to {player}'s experience. They are now level {level} with {playerEXP} experience." + ); + private List setExp = Collections.singletonList( + "&aSet {player}'s experience to {setEXP}. They are now level {level} with {playerEXP} experience." + ); + private List removedExp = Collections.singletonList( + "&aRemoved {removedEXP} from {player}'s experience. They are now level {level} with {playerEXP} experience." + ); - private List addedLevels = Collections.singletonList("&aAdded {addedLevels} to {player}'s level(s). They are now level {level} with {playerEXP} experience."); - private List setLevel = Collections.singletonList("&aSet {player}'s level to {setLevel}. They are now level {level} with {playerEXP} experience."); - private List removedLevels = Collections.singletonList("&aRemoved {removedLevels} from {player}'s level(s). They are now level {level} with {playerEXP} experience."); + private List addedLevels = Collections.singletonList( + "&aAdded {addedLevels} to {player}'s level(s). They are now level {level} with {playerEXP} experience." + ); + private List setLevel = Collections.singletonList( + "&aSet {player}'s level to {setLevel}. They are now level {level} with {playerEXP} experience." + ); + private List removedLevels = Collections.singletonList( + "&aRemoved {removedLevels} from {player}'s level(s). They are now level {level} with {playerEXP} experience." + ); - private List playerNotFound = Collections.singletonList("&cThe player {player} is not found in the database!"); - private List notNumber = Collections.singletonList("&cThat is not a number!"); - private List purgePlayer = Collections.singletonList("&cThe player {player} was removed from CLV''s data."); + private List playerNotFound = Collections.singletonList( + "&cThe player {player} is not found in the database!" + ); + private List notNumber = Collections.singletonList( + "&cThat is not a number!" + ); + private List purgePlayer = Collections.singletonList( + "&cThe player {player} was removed from CLV''s data." + ); private List levelInfo = Arrays.asList( - "[C] &8&m―――――――&8<&d&l Level&f&lStats &8>&8&m―――――――", - "[C] &7Player: &f{player}", - "[C] &7Level: &f{level}&7/&d{maxLevel}", - "[C] &7EXP: &f{playerEXP}&7/&d{requiredEXP} &7[&f{percent}%&7]", - "[C] &7[{progressBar}&7]", - "[C] &8&m――――――――――――――――――――――――――――――――" + "[C] &8&m―――――――&8<&d&l Level&f&lStats &8>&8&m―――――――", + "[C] &7Player: &f{player}", + "[C] &7Level: &f{level}&7/&d{maxLevel}", + "[C] &7EXP: &f{playerEXP}&7/&d{requiredEXP} &7[&f{percent}%&7]", + "[C] &7[{progressBar}&7]", + "[C] &8&m――――――――――――――――――――――――――――――――" + ); + + private List gainedExp = Collections.singletonList( + "[actionbar] &d+{gainedEXP} EXP" + ); + private List lostExp = Collections.singletonList( + "[actionbar] &c-{lostEXP} EXP" + ); + private List gainedLevels = Collections.singletonList( + "[actionbar] &d+{gainedLevels} Level(s)" + ); + private List lostLevels = Collections.singletonList( + "[actionbar] &c-{lostLevels} Level(s)" ); - private List gainedExp = Collections.singletonList("[actionbar] &d+{gainedEXP} EXP"); - private List lostExp = Collections.singletonList("[actionbar] &c-{lostEXP} EXP"); - private List gainedLevels = Collections.singletonList("[actionbar] &d+{gainedLevels} Level(s)"); - private List lostLevels = Collections.singletonList("[actionbar] &c-{lostLevels} Level(s)"); + private List topHeader = Collections.singletonList( + "[C] &8&m―――――――&8<&d&l Top &f&lPlayers &8>&8&m―――――――" + ); + private List topContent = Collections.singletonList( + "&f[{position}] &d{player}&7: &7level: &f{level}&7, exp: &f{exp}" + ); + private List topFooter = Collections.singletonList( + "[C] &8&m――――――――――――――――――――――――――――――――" + ); - private List topHeader = Collections.singletonList("[C] &8&m―――――――&8<&d&l Top &f&lPlayers &8>&8&m―――――――"); - private List topContent = Collections.singletonList("&f[{position}] &d{player}&7: &7level: &f{level}&7, exp: &f{exp}"); - private List topFooter = Collections.singletonList("[C] &8&m――――――――――――――――――――――――――――――――"); + private List spigotUpdateNewerChat = Arrays.asList( + "[C] &7A newer version is listed on Spigot: &d{remoteVersion}&7 (you are on &f{localVersion}&7).", + "[C] &7Download: &f{resourceUrl}" + ); + private List spigotUpdateEarlyAccessChat = Arrays.asList( + "[C] &7Early access build (&f{localVersion}&7).", + "[C] &7If you encounter issues, report on Discord: &d{discordUrl}" + ); @Getter(AccessLevel.NONE) private final CyberLevels main; @@ -92,65 +146,238 @@ public class Lang { Lang(CyberLevels main) { this.main = main; try { - prefix = (file = new CLVFile(main, "lang")).get("messages.prefix", prefix); - noPermission = Configurable.toStringList(file.getConfiguration(), "messages.no-permission", noPermission); - - helpPlayer = Configurable.toStringList(file.getConfiguration(), "messages.help-player", helpPlayer); - helpAdmin = Configurable.toStringList(file.getConfiguration(), "messages.help-admin", helpAdmin); + prefix = (file = new CLVFile(main, "lang")).get( + "messages.prefix", + prefix + ); + noPermission = Configurable.toStringList( + file.getConfiguration(), + "messages.no-permission", + noPermission + ); + + helpPlayer = Configurable.toStringList( + file.getConfiguration(), + "messages.help-player", + helpPlayer + ); + helpAdmin = Configurable.toStringList( + file.getConfiguration(), + "messages.help-admin", + helpAdmin + ); progressBar = file.get("messages.progress.bar", progressBar); - progressCompleteColor = file.get("messages.progress.complete-color", progressCompleteColor); - progressIncompleteColor = file.get("messages.progress.incomplete-color", progressIncompleteColor); - progressEndColor = file.get("messages.progress.end-color", progressEndColor); - - reloading = Configurable.toStringList(file.getConfiguration(), "messages.reloading", reloading); - reloaded = Configurable.toStringList(file.getConfiguration(), "messages.reloaded", reloaded); - autoSave = Configurable.toStringList(file.getConfiguration(), "messages.auto-save", autoSave); - - addedExp = Configurable.toStringList(file.getConfiguration(), "messages.added-exp", addedExp); - setExp = Configurable.toStringList(file.getConfiguration(), "messages.set-exp", setExp); - removedExp = Configurable.toStringList(file.getConfiguration(), "messages.removed-exp", removedExp); - - addedLevels = Configurable.toStringList(file.getConfiguration(), "messages.added-levels", addedLevels); - setLevel = Configurable.toStringList(file.getConfiguration(), "messages.set-level", setLevel); - removedLevels = Configurable.toStringList(file.getConfiguration(), "messages.removed-levels", removedLevels); - - playerNotFound = Configurable.toStringList(file.getConfiguration(), "messages.player-not-found", playerNotFound); - notNumber = Configurable.toStringList(file.getConfiguration(), "messages.not-number", notNumber); - purgePlayer = Configurable.toStringList(file.getConfiguration(), "messages.purge-player", purgePlayer); - - levelInfo = Configurable.toStringList(file.getConfiguration(), "messages.level-info", levelInfo); - - gainedExp = Configurable.toStringList(file.getConfiguration(), "messages.gained-exp", gainedExp); - lostExp = Configurable.toStringList(file.getConfiguration(), "messages.lost-exp", lostExp); - gainedLevels = Configurable.toStringList(file.getConfiguration(), "messages.gained-levels", gainedLevels); - lostLevels = Configurable.toStringList(file.getConfiguration(), "messages.lost-levels", lostLevels); - - topHeader = Configurable.toStringList(file.getConfiguration(), "messages.top-header", topHeader); - topContent = Configurable.toStringList(file.getConfiguration(), "messages.top-content", topContent); - topFooter = Configurable.toStringList(file.getConfiguration(), "messages.top-footer", topFooter); - - leaderboardKeys = new LeaderboardKeys(file.getSection("messages.leaderboard-placeholders")); - } - catch (IOException ignored) {} + progressCompleteColor = file.get( + "messages.progress.complete-color", + progressCompleteColor + ); + progressIncompleteColor = file.get( + "messages.progress.incomplete-color", + progressIncompleteColor + ); + progressEndColor = file.get( + "messages.progress.end-color", + progressEndColor + ); + + reloading = Configurable.toStringList( + file.getConfiguration(), + "messages.reloading", + reloading + ); + reloaded = Configurable.toStringList( + file.getConfiguration(), + "messages.reloaded", + reloaded + ); + autoSave = Configurable.toStringList( + file.getConfiguration(), + "messages.auto-save", + autoSave + ); + + addedExp = Configurable.toStringList( + file.getConfiguration(), + "messages.added-exp", + addedExp + ); + setExp = Configurable.toStringList( + file.getConfiguration(), + "messages.set-exp", + setExp + ); + removedExp = Configurable.toStringList( + file.getConfiguration(), + "messages.removed-exp", + removedExp + ); + + addedLevels = Configurable.toStringList( + file.getConfiguration(), + "messages.added-levels", + addedLevels + ); + setLevel = Configurable.toStringList( + file.getConfiguration(), + "messages.set-level", + setLevel + ); + removedLevels = Configurable.toStringList( + file.getConfiguration(), + "messages.removed-levels", + removedLevels + ); + + playerNotFound = Configurable.toStringList( + file.getConfiguration(), + "messages.player-not-found", + playerNotFound + ); + notNumber = Configurable.toStringList( + file.getConfiguration(), + "messages.not-number", + notNumber + ); + purgePlayer = Configurable.toStringList( + file.getConfiguration(), + "messages.purge-player", + purgePlayer + ); + + levelInfo = Configurable.toStringList( + file.getConfiguration(), + "messages.level-info", + levelInfo + ); + + gainedExp = Configurable.toStringList( + file.getConfiguration(), + "messages.gained-exp", + gainedExp + ); + lostExp = Configurable.toStringList( + file.getConfiguration(), + "messages.lost-exp", + lostExp + ); + gainedLevels = Configurable.toStringList( + file.getConfiguration(), + "messages.gained-levels", + gainedLevels + ); + lostLevels = Configurable.toStringList( + file.getConfiguration(), + "messages.lost-levels", + lostLevels + ); + + topHeader = Configurable.toStringList( + file.getConfiguration(), + "messages.top-header", + topHeader + ); + topContent = Configurable.toStringList( + file.getConfiguration(), + "messages.top-content", + topContent + ); + topFooter = Configurable.toStringList( + file.getConfiguration(), + "messages.top-footer", + topFooter + ); + + spigotUpdateNewerChat = Configurable.toStringList( + file.getConfiguration(), + "messages.spigot-update-newer-chat", + spigotUpdateNewerChat + ); + spigotUpdateEarlyAccessChat = Configurable.toStringList( + file.getConfiguration(), + "messages.spigot-update-early-access-chat", + spigotUpdateEarlyAccessChat + ); + + leaderboardKeys = new LeaderboardKeys( + file.getSection("messages.leaderboard-placeholders") + ); + } catch (IOException ignored) {} } + /** + * Persists automatic updates for the underlying language file when supported by the backing + * {@link CLVFile}. + * + *

This keeps the physical file aligned with the current template but does not reconstruct the + * current cache instance. + */ public void update() { if (file != null) file.update(); } - public boolean sendMessage(Player player, Function> function, String[] keys, Object... values) { - return new Message().player(player).list(function).keys(keys).values(values).send(); + /** + * Sends a configurable multi-line message to a player using a language accessor and an ordered + * placeholder set. + * + * @param player player that should receive the message + * @param function language accessor used to resolve the message lines + * @param keys placeholder keys that should be populated + * @param values placeholder values in the same order as {@code keys} + * @return {@code true} when the message was dispatched successfully + */ + public boolean sendMessage( + Player player, + Function> function, + String[] keys, + Object... values + ) { + return new Message() + .player(player) + .list(function) + .keys(keys) + .values(values) + .send(); } - public boolean sendMessage(Player player, Function> function, String key, Object value) { - return sendMessage(player, function, new String[] {key}, value.toString()); + /** + * Sends a configurable message to a player using a single placeholder pair. + * + * @param player player that should receive the message + * @param function language accessor used to resolve the message lines + * @param key placeholder key that should be populated + * @param value placeholder value to insert + * @return {@code true} when the message was dispatched successfully + */ + public boolean sendMessage( + Player player, + Function> function, + String key, + Object value + ) { + return sendMessage(player, function, new String[] { key }, value.toString()); } - public boolean sendMessage(Player player, Function> function) { + /** + * Sends a configurable message to a player without placeholders. + * + * @param player player that should receive the message + * @param function language accessor used to resolve the message lines + * @return {@code true} when the message was dispatched successfully + */ + public boolean sendMessage( + Player player, + Function> function + ) { return sendMessage(player, function, null); } + /** + * Placeholder defaults used when the leaderboard is empty or still loading. + * + *

These values back placeholder expansions such as the top player name, level, and EXP for a + * position that has not been resolved yet. + */ @Getter public static class LeaderboardKeys { diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/Levels.java b/src/main/java/com/bitaspire/cyberlevels/cache/Levels.java index bbe710d..ae8dce9 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/Levels.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/Levels.java @@ -1,15 +1,21 @@ package com.bitaspire.cyberlevels.cache; import com.bitaspire.cyberlevels.CyberLevels; -import lombok.AccessLevel; import lombok.Getter; -import org.apache.commons.lang.StringUtils; -import org.jetbrains.annotations.Nullable; +import org.apache.commons.lang3.StringUtils; import java.io.IOException; import java.util.HashMap; import java.util.Map; +/** + * Parsed view of the level progression configuration. + * + *

This cache stores the starting point of the progression system, the configured maximum level, + * the default experience formula, and any per-level formula overrides defined in + * {@code levels.yml}. The values are later consumed by the active {@code LevelSystem} + * implementation when calculating required experience. + */ @Getter public class Levels { diff --git a/src/main/java/com/bitaspire/cyberlevels/cache/Rewards.java b/src/main/java/com/bitaspire/cyberlevels/cache/Rewards.java index 684be9a..634af2c 100644 --- a/src/main/java/com/bitaspire/cyberlevels/cache/Rewards.java +++ b/src/main/java/com/bitaspire/cyberlevels/cache/Rewards.java @@ -4,9 +4,8 @@ import com.bitaspire.cyberlevels.level.Reward; import lombok.Getter; import me.clip.placeholderapi.PlaceholderAPI; -import me.croabeast.beanslib.message.MessageSender; -import me.croabeast.file.Configurable; -import org.apache.commons.lang.StringUtils; +import com.bitaspire.libs.file.Configurable; +import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.configuration.ConfigurationSection; @@ -16,6 +15,14 @@ import java.io.IOException; import java.util.*; +/** + * Cache of level rewards loaded from {@code rewards.yml}. + * + *

Rewards are grouped by level and can bundle console/player commands, chat output, and an + * optional sound effect. The outer cache is responsible for parsing the configuration into + * executable reward objects, while each individual reward implementation knows how to deliver its + * side effects to a player. + */ public final class Rewards { private final CyberLevels main; @@ -112,7 +119,7 @@ public void executeCommands(Player player) { } void typeMessage(Player player, String message) { - new MessageSender(player).setLogger(false).send(message); + main.createSender(player).setLogger(false).send(message); } public void sendMessages(Player player) { diff --git a/src/main/java/com/bitaspire/cyberlevels/command/CLVCommand.java b/src/main/java/com/bitaspire/cyberlevels/command/CLVCommand.java index 7bfdd1f..5cd65d4 100644 --- a/src/main/java/com/bitaspire/cyberlevels/command/CLVCommand.java +++ b/src/main/java/com/bitaspire/cyberlevels/command/CLVCommand.java @@ -4,34 +4,81 @@ import com.bitaspire.cyberlevels.cache.Lang; import com.bitaspire.cyberlevels.level.LevelSystem; import com.bitaspire.cyberlevels.user.LevelUser; +import com.bitaspire.libs.common.util.ReplaceUtils; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; import lombok.Getter; -import me.croabeast.beanslib.message.MessageSender; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; -import java.util.List; -import java.util.function.Function; - +/** + * Handles the main {@code /clv} command tree. + * + *

This executor is responsible for player-facing informational commands, administrative EXP and + * level mutations, reloads, leaderboard output, and console-compatible fallbacks. It also bridges + * configurable language messages to both players and the console so command feedback stays aligned + * with {@code lang.yml}. + */ public class CLVCommand implements CommandExecutor { private final CyberLevels main; private final List consoleCmds; + /** + * Creates the command executor bound to the current plugin runtime. + * + * @param main owning plugin instance + */ public CLVCommand(CyberLevels main) { this.main = main; - this.consoleCmds = Arrays.asList("about", "reload", "addexp", "setexp", "removeexp", "addlevel", "setlevel", "removelevel", "purge"); + this.consoleCmds = Arrays.asList( + "about", + "reload", + "addexp", + "setexp", + "removeexp", + "addlevel", + "setlevel", + "removelevel", + "purge" + ); } + /** + * Executes the {@code /clv} command and its subcommands. + * + *

The method supports both player and console senders, validates permissions, resolves the + * target player when needed, applies EXP or level changes, and renders feedback using the + * configured language cache. + * + * @param sender command sender invoking the command + * @param cmd Bukkit command metadata + * @param label alias used to invoke the command + * @param args raw subcommand arguments + * @return always {@code true} because the command handles its own usage feedback + */ @Override - public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, String[] args) { + public boolean onCommand( + @NotNull CommandSender sender, + @NotNull Command cmd, + @NotNull String label, + String[] args + ) { Player player = (sender instanceof Player) ? (Player) sender : null; - // Console restrictions - if (player == null && (args.length == 0 || !consoleCmds.contains(args[0].toLowerCase()))) { + if ( + player == null && + (args.length == 0 || !consoleCmds.contains(args[0].toLowerCase())) + ) { main.logger("&cConsole cannot use this command!"); return true; } @@ -42,40 +89,53 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N if (args.length == 1) { switch (sub) { case "about": - return isRestricted(player, "player.about") || new MessageSender(player).send( - " &d&lCyber&f&lLevels &fv" + main.getDescription().getVersion() + " &7(&7&nhttps://bit.ly/2YSlqYq&7).", + if (isRestricted(player, "player.about")) return true; + if (player == null) { + main.logger( + " &d&lCyber&f&lLevels &fv" + + main.getDescription().getVersion() + + " &7(&7&nhttps://bit.ly/2YSlqYq&7).", " &fDeveloped by &d" + main.getAuthors() + "&f.", " A leveling system plugin with MySQL support and custom events." + ); + return true; + } + return main.createSender(player).send( + " &d&lCyber&f&lLevels &fv" + + main.getDescription().getVersion() + + " &7(&7&nhttps://bit.ly/2YSlqYq&7).", + " &fDeveloped by &d" + main.getAuthors() + "&f.", + " A leveling system plugin with MySQL support and custom events." ); - case "reload": if (isRestricted(player, "admin.reload")) return true; - main.cache().lang().sendMessage(player, Lang::getReloading); - main.onDisable(); + sendLangMessage(sender, player, Lang::getReloading); main.reloadPlugin(); - - return main.cache().lang().sendMessage(player, Lang::getReloaded); - - case "info": return sendLevelInfo(player); - + return sendLangMessage(sender, player, Lang::getReloaded); + case "info": + return sendLevelInfo(player); case "top": if (isRestricted(player, "player.top")) return true; - main.cache().lang().sendMessage(player, Lang::getTopHeader); + sendLangMessage(sender, player, Lang::getTopHeader); int i = 1; - - for (LevelUser user : main.levelSystem().getLeaderboard().getTopTenPlayers()) { - main.cache().lang().sendMessage( - player, Lang::getTopContent, - new String[] {"position", "player", "level", "exp"}, - i++, user.getName(), - user.getLevel(), user.getExp() + for (LevelUser user : main + .levelSystem() + .getLeaderboard() + .getTopTenPlayers()) { + sendLangMessage( + sender, + player, + Lang::getTopContent, + new String[] { "position", "player", "level", "exp" }, + i++, + user.getName(), + user.getLevel(), + user.getExp() ); } - - main.cache().lang().sendMessage(player, Lang::getTopFooter); - return true; + return sendLangMessage(sender, player, Lang::getTopFooter); } } @@ -84,7 +144,12 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N if (target != null) { main.userManager().removeUser(target.getUuid()); main.levelSystem().getLeaderboard().update(); - return main.cache().lang().sendMessage(player, Lang::getPurgePlayer, "player", args[1]); + return sendLangMessage( + sender, + player, + Lang::getPurgePlayer, + args[1] + ); } return isRestricted(player, "admin.info") || sendLevelInfo(player); @@ -94,58 +159,223 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N if (isRestricted(player, "admin.info")) return true; LevelUser target = main.userManager().getUser(args[1]); - if (target == null) - return main.cache().lang().sendMessage(player, Lang::getPlayerNotFound, "player", args[1]); + if (target == null) { + return sendLangMessage( + sender, + player, + Lang::getPlayerNotFound, + args[1] + ); + } return sendLevelInfo(player, target); } if (args.length >= 2) { - LevelUser user = null; - - if (args.length == 3) - user = main.userManager().getUser(args[2]); - - if (user == null && player != null) + final String targetName = args.length >= 3 ? args[2] : null; + + LevelUser user; + if (targetName != null) { + user = resolveUserByName(targetName); + if (user == null) { + return sendLangMessage( + sender, + player, + Lang::getPlayerNotFound, + targetName + ); + } + } else { + if (player == null) { + main.logger( + "&cConsole must specify a player name for this command." + ); + return true; + } user = main.userManager().getUser(player); - - if (user == null) { - main.cache().lang().sendMessage(player, Lang::getPlayerNotFound, "player", args[2]); - return true; + if (user == null) { + return sendLangMessage( + sender, + player, + Lang::getPlayerNotFound, + player.getName() + ); + } } String value = args[1]; - switch (sub) { case "addexp": - return handleExp(player, user, value, "exp.add", true, ExpAction.ADD); - + return handleExp( + sender, + player, + user, + value, + "exp.add", + true, + ExpAction.ADD + ); case "setexp": - return handleExp(player, user, value, "exp.set", true, ExpAction.SET); - + return handleExp( + sender, + player, + user, + value, + "exp.set", + true, + ExpAction.SET + ); case "removeexp": - return handleExp(player, user, value, "exp.remove", false, ExpAction.REMOVE); - + return handleExp( + sender, + player, + user, + value, + "exp.remove", + false, + ExpAction.REMOVE + ); case "addlevel": - return handleLevel(player, user, value, "level.add", LevelAction.ADD); - + return handleLevel( + sender, + player, + user, + value, + "level.add", + LevelAction.ADD + ); case "setlevel": - return handleLevel(player, user, value, "level.set", LevelAction.SET); - + return handleLevel( + sender, + player, + user, + value, + "level.set", + LevelAction.SET + ); case "removelevel": - return handleLevel(player, user, value, "level.remove", LevelAction.REMOVE); + return handleLevel( + sender, + player, + user, + value, + "level.remove", + LevelAction.REMOVE + ); } } if (player != null) { - if (player.hasPermission("CyberLevels.admin.help")) + if (player.hasPermission("CyberLevels.admin.help")) { return main.cache().lang().sendMessage(player, Lang::getHelpAdmin); + } + + if (player.hasPermission("CyberLevels.player.help")) { + return main + .cache() + .lang() + .sendMessage(player, Lang::getHelpPlayer); + } + } + + return sendLangMessage(sender, player, Lang::getNoPermission); + } - if (player.hasPermission("CyberLevels.player.help")) - return main.cache().lang().sendMessage(player, Lang::getHelpPlayer); + private boolean sendLangMessage( + CommandSender cmdSender, + Player player, + Function> langFn + ) { + if (player != null) { + return main.cache().lang().sendMessage(player, langFn); + } + + List lines = langFn.apply(main.cache().lang()); + if (lines == null || lines.isEmpty()) return true; + + for (String line : lines) { + String out = stripConsoleChannels(line); + if (!out.isEmpty()) { + cmdSender.sendMessage( + ChatColor.translateAlternateColorCodes('&', out) + ); + } + } + return true; + } + + private boolean sendLangMessage( + CommandSender cmdSender, + Player player, + Function> langFn, + Object value + ) { + return sendLangMessage( + cmdSender, + player, + langFn, + new String[] {"player"}, + value + ); + } + + private boolean sendLangMessage( + CommandSender cmdSender, + Player player, + Function> langFn, + String[] keys, + Object... values + ) { + if (player != null) { + return main.cache().lang().sendMessage(player, langFn, keys, values); + } + + List lines = langFn.apply(main.cache().lang()); + if (lines == null || lines.isEmpty()) return true; + + Map placeholders = new LinkedHashMap<>(); + if (keys != null && values != null && keys.length == values.length) { + for (int i = 0; i < keys.length; i++) { + placeholders.put( + '{' + keys[i] + '}', + String.valueOf(values[i]) + ); + } } - return main.cache().lang().sendMessage(player, Lang::getNoPermission); + for (String line : lines) { + String out = ReplaceUtils.replaceEach(placeholders, line); + out = stripConsoleChannels(out); + if (!out.isEmpty()) { + cmdSender.sendMessage( + ChatColor.translateAlternateColorCodes('&', out) + ); + } + } + return true; + } + + private static String stripConsoleChannels(String line) { + return line == null ? + "" : + line.replaceFirst("^\\[C]\\s*", "") + .replace("[actionbar]", "") + .replace("[action-bar]", "").trim(); + } + + private LevelUser resolveUserByName(String name) { + if (name == null || name.trim().isEmpty()) return null; + + LevelUser user = main.userManager().getUser(name); + if (user != null) return user; + + Player online = Bukkit.getPlayerExact(name); + if (online != null) return main.userManager().getUser(online); + + OfflinePlayer offline = Bukkit.getOfflinePlayer(name); + if (!offline.hasPlayedBefore() && !offline.isOnline()) return null; + + return main.userManager().getUser(offline.getUniqueId()); } private boolean sendLevelInfo(Player player) { @@ -153,14 +383,24 @@ private boolean sendLevelInfo(Player player) { LevelSystem system = main.levelSystem(); return main.cache().lang().sendMessage( - player, Lang::getLevelInfo, - new String[] {"player", "level", "maxLevel", "playerEXP", "requiredEXP", "percent", "progressBar"}, - user.getName(), user.getLevel(), - main.cache().levels().getMaxLevel(), - system.formatNumber(user.getExp()), - system.formatNumber(user.getRequiredExp()), - user.getPercent(), - user.getProgressBar() + player, + Lang::getLevelInfo, + new String[] { + "player", + "level", + "maxLevel", + "playerEXP", + "requiredEXP", + "percent", + "progressBar", + }, + user.getName(), + user.getLevel(), + main.cache().levels().getMaxLevel(), + system.formatNumber(user.getExp()), + system.formatNumber(user.getRequiredExp()), + user.getPercent(), + user.getProgressBar() ); } @@ -168,48 +408,96 @@ private boolean sendLevelInfo(Player viewer, LevelUser target) { LevelSystem system = main.levelSystem(); return main.cache().lang().sendMessage( - viewer, Lang::getLevelInfo, - new String[]{"player","level","maxLevel","playerEXP","requiredEXP","percent","progressBar"}, - target.getName(), - target.getLevel(), - main.cache().levels().getMaxLevel(), - system.formatNumber(target.getExp()), - system.formatNumber(target.getRequiredExp()), - target.getPercent(), - target.getProgressBar() + viewer, + Lang::getLevelInfo, + new String[] { + "player", + "level", + "maxLevel", + "playerEXP", + "requiredEXP", + "percent", + "progressBar", + }, + target.getName(), + target.getLevel(), + main.cache().levels().getMaxLevel(), + system.formatNumber(target.getExp()), + system.formatNumber(target.getRequiredExp()), + target.getPercent(), + target.getProgressBar() ); } - private boolean handleExp(Player player, LevelUser user, String arg, String perm, boolean allowMultiplier, ExpAction action) { + private boolean handleExp( + CommandSender sender, + Player player, + LevelUser user, + String arg, + String perm, + boolean allowMultiplier, + ExpAction action + ) { perm = "admin.levels." + perm; - if (isRestricted(player, perm) || notDouble(player, arg)) return true; - double value = Math.abs(Double.parseDouble(arg)); + if (isRestricted(player, perm) || notDouble(sender, player, arg)) { + return true; + } + + double value; + try { + value = Math.abs(Double.parseDouble(arg)); + } catch (Exception ignored) { + return true; + } switch (action) { case ADD: - user.addExp(value + "", main.cache().config().isMultiplierCommands()); + user.addExp( + value, + main.cache().config().isMultiplierCommands() + ); break; case SET: - user.setExp(value + "", allowMultiplier, true, true); + user.setExp(value, allowMultiplier, true, true); break; case REMOVE: - user.removeExp(value + ""); + user.removeExp(value); break; } LevelSystem system = main.levelSystem(); - return main.cache().lang().sendMessage( - player, action.getMessage(), - new String[] {"player", action.getPlaceholder(), "level", "playerEXP"}, - user.getName(), arg, user.getLevel(), system.formatNumber(user.getExp()) + return sendLangMessage( + sender, + player, + action.getMessage(), + new String[] { "player", action.getPlaceholder(), "level", "playerEXP" }, + user.getName(), + arg, + user.getLevel(), + system.formatNumber(user.getExp()) ); } - private boolean handleLevel(Player player, LevelUser user, String arg, String perm, LevelAction action) { - if (isRestricted(player, perm) || notLong(player, arg)) return true; - long value = Math.abs(Long.parseLong(arg)); + private boolean handleLevel( + CommandSender sender, + Player player, + LevelUser user, + String arg, + String perm, + LevelAction action + ) { + if (isRestricted(player, perm) || notLong(sender, player, arg)) { + return true; + } + + long value; + try { + value = Math.abs(Long.parseLong(arg)); + } catch (Exception ignored) { + return true; + } switch (action) { case ADD: @@ -225,33 +513,41 @@ private boolean handleLevel(Player player, LevelUser user, String arg, String LevelSystem system = main.levelSystem(); - return main.cache().lang().sendMessage( - player, action.getMessage(), - new String[] {"player", action.getPlaceholder(), "level", "playerEXP"}, - user.getName(), arg, user.getLevel(), system.formatNumber(user.getExp()) + return sendLangMessage( + sender, + player, + action.getMessage(), + new String[] { "player", action.getPlaceholder(), "level", "playerEXP" }, + user.getName(), + arg, + user.getLevel(), + system.formatNumber(user.getExp()) ); } private boolean isRestricted(Player player, String permissionKey) { - return player != null && (!player.hasPermission("CyberLevels." + permissionKey) && - main.cache().lang().sendMessage(player, Lang::getNoPermission)); + return ( + player != null && + (!player.hasPermission("CyberLevels." + permissionKey) && + main.cache().lang().sendMessage(player, Lang::getNoPermission)) + ); } - private boolean notLong(Player player, String arg) { + private boolean notLong(CommandSender sender, Player player, String arg) { try { Long.parseLong(arg); return false; } catch (Exception e) { - return main.cache().lang().sendMessage(player, Lang::getNotNumber); + return sendLangMessage(sender, player, Lang::getNotNumber); } } - private boolean notDouble(Player player, String arg) { + private boolean notDouble(CommandSender sender, Player player, String arg) { try { Double.parseDouble(arg); return false; } catch (Exception e) { - return main.cache().lang().sendMessage(player, Lang::getNotNumber); + return sendLangMessage(sender, player, Lang::getNotNumber); } } @@ -261,6 +557,7 @@ private enum ExpAction { REMOVE(Lang::getRemovedExp, "removedEXP"); private final Function> lang; + @Getter private final String placeholder; @@ -280,6 +577,7 @@ private enum LevelAction { REMOVE(Lang::getRemovedLevels, "removedLevels"); private final Function> lang; + @Getter private final String placeholder; diff --git a/src/main/java/com/bitaspire/cyberlevels/command/CLVTabComplete.java b/src/main/java/com/bitaspire/cyberlevels/command/CLVTabComplete.java index ecf45ad..28d6641 100644 --- a/src/main/java/com/bitaspire/cyberlevels/command/CLVTabComplete.java +++ b/src/main/java/com/bitaspire/cyberlevels/command/CLVTabComplete.java @@ -1,5 +1,7 @@ package com.bitaspire.cyberlevels.command; +import com.bitaspire.cyberlevels.CyberLevels; +import lombok.RequiredArgsConstructor; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; @@ -11,12 +13,30 @@ import java.util.*; +/** + * Tab completer for the {@code /clv} command tree. + * + *

The completer filters suggestions by permission, offers common numeric examples for mutation + * commands, and can resolve either online-only or online-plus-offline player names depending on + * the current configuration. + */ +@RequiredArgsConstructor public class CLVTabComplete implements TabCompleter { private static final String PLAYER_PREFIX = "CyberLevels.player."; private static final String ADMIN_PREFIX = "CyberLevels.admin."; - - private static final Map COMMAND_PERMISSIONS = new HashMap<>(); + private static final long PLAYER_NAME_CACHE_MILLIS = 5000L; + + private static final List EXP_AMOUNT_SUGGESTIONS = Collections.unmodifiableList( + Arrays.asList("", "5", "100", "250", "1000") + ); + private static final List LEVEL_AMOUNT_SUGGESTIONS = Collections.unmodifiableList( + Arrays.asList("", "1", "2", "5") + ); + private static final Set MUTATION_COMMANDS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("addexp", "setexp", "removeexp", "addlevel", "setlevel", "removelevel") + )); + private static final Map COMMAND_PERMISSIONS = new LinkedHashMap<>(); static { COMMAND_PERMISSIONS.put("about", PLAYER_PREFIX + "about"); @@ -37,6 +57,20 @@ public class CLVTabComplete implements TabCompleter { COMMAND_PERMISSIONS.put("removeLevel", ADMIN_PREFIX + "levels.remove"); } + private final CyberLevels main; + private long playerNamesCachedAt = 0L; + private boolean playerNamesCachedOfflineMode = false; + private List cachedPlayerNames = Collections.emptyList(); + + /** + * Produces context-aware tab completions for the CyberLevels command set. + * + * @param sender command sender requesting completions + * @param command Bukkit command metadata + * @param alias alias used to invoke the command + * @param args current partial arguments + * @return ordered list of suggested completions for the current argument position + */ @Override public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, String[] args) { if (!(sender instanceof Player)) return Collections.emptyList(); @@ -63,16 +97,14 @@ public List onTabComplete(@NotNull CommandSender sender, @NotNull Comman break; case "addexp": case "setexp": case "removeexp": - return partialMatch(args[1], Arrays.asList("", "5", "100", "250", "1000")); + return partialMatch(args[1], EXP_AMOUNT_SUGGESTIONS); case "addlevel": case "setlevel": case "removelevel": - return partialMatch(args[1], Arrays.asList("", "1", "2", "5")); + return partialMatch(args[1], LEVEL_AMOUNT_SUGGESTIONS); } } - if (args.length == 3 && - Arrays.asList("addexp", "setexp", "removeexp", "addlevel", "setlevel", "removelevel") - .contains(args[0].toLowerCase())) + if (args.length == 3 && MUTATION_COMMANDS.contains(args[0].toLowerCase(Locale.ENGLISH))) { List suggestions = new ArrayList<>(); suggestions.add("[]"); @@ -84,20 +116,38 @@ public List onTabComplete(@NotNull CommandSender sender, @NotNull Comman } private List getPlayerNames() { - List players = new ArrayList<>(); + boolean offlineMode = main.cache().config().isTabCompleteLoadOfflineUsers(); + long now = System.currentTimeMillis(); + + if (offlineMode == playerNamesCachedOfflineMode && + now - playerNamesCachedAt <= PLAYER_NAME_CACHE_MILLIS) + return cachedPlayerNames; + + LinkedHashSet players = new LinkedHashSet<>(); - for (OfflinePlayer p : Bukkit.getOfflinePlayers()) { - String name = p.getName(); - if (name != null) players.add(name); + if (offlineMode) { + for (OfflinePlayer p : Bukkit.getOfflinePlayers()) { + String name = p.getName(); + if (name != null) players.add(name); + } + } else { + for (Player player : Bukkit.getOnlinePlayers()) { + players.add(player.getName()); + } } - return players; + List snapshot = new ArrayList<>(players); + snapshot.sort(String.CASE_INSENSITIVE_ORDER); + cachedPlayerNames = snapshot; + playerNamesCachedOfflineMode = offlineMode; + playerNamesCachedAt = now; + return cachedPlayerNames; } private List partialMatch(String input, List options) { List matches = new ArrayList<>(); StringUtil.copyPartialMatches(input, options, matches); - Collections.sort(matches); + matches.sort(String.CASE_INSENSITIVE_ORDER); return matches; } } diff --git a/src/main/java/com/bitaspire/cyberlevels/event/ExpChangeEvent.java b/src/main/java/com/bitaspire/cyberlevels/event/ExpChangeEvent.java new file mode 100644 index 0000000..9bf4970 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/event/ExpChangeEvent.java @@ -0,0 +1,139 @@ +package com.bitaspire.cyberlevels.event; + +import com.bitaspire.cyberlevels.user.LevelUser; +import lombok.Getter; +import lombok.Setter; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Fired when CyberLevels is about to apply an EXP change to a user. + * + *

The event exposes both the previous and projected EXP/level values so listeners can inspect + * the full transition. The mutable {@link #expAmount} represents the delta that will actually be + * applied and may be adjusted by listeners before the change is finalized. + */ +@Getter +public class ExpChangeEvent extends Event { + + private static final HandlerList handlerList = new HandlerList(); + + /** + * User whose EXP is being modified. + */ + private final LevelUser user; + /** + * EXP value before the pending change is applied. + */ + private final double oldExp, newExp; + /** + * Level value before and after the pending change is applied. + */ + private final double oldLevel, newLevel; + /** + * Mutable delta that CyberLevels intends to apply. + */ + @Setter + private double expAmount; + + /** + * Creates a new EXP change event snapshot. + * + * @param user affected user + * @param oldExp EXP before the change + * @param oldLevel level before the change + * @param newExp projected EXP after the change + * @param newLevel projected level after the change + * @param expAmount mutable EXP delta that will be applied + */ + public ExpChangeEvent(LevelUser user, double oldExp, double oldLevel, double newExp, double newLevel, double expAmount) { + super(!Bukkit.isPrimaryThread()); + + this.user = user; + this.oldExp = oldExp; + this.oldLevel = oldLevel; + this.newExp = newExp; + this.newLevel = newLevel; + this.expAmount = expAmount; + } + + /** + * Dispatches this event through Bukkit's plugin manager. + * + *

This helper exists so internal callers can create and emit the event in one fluent step. + */ + public void call() { + Bukkit.getPluginManager().callEvent(this); + } + + /** + * Returns the live Bukkit player for integrations that expect a direct player method. + * + *

This is an alias for {@code getUser().getPlayer()} and is especially useful for + * reflection-based hooks that cannot consume CyberLevels' {@link LevelUser} wrapper directly. + * + * @return affected online player + */ + @NotNull + public Player getPlayer() { + return user.getPlayer(); + } + + /** + * Legacy-style alias for the mutable EXP delta. + * + * @return EXP amount that will be applied + */ + public double getAmount() { + return expAmount; + } + + /** + * Legacy-style alias for changing the mutable EXP delta. + * + * @param amount replacement EXP amount + */ + public void setAmount(double amount) { + this.expAmount = amount; + } + + /** + * Legacy-style alias for the previous EXP value. + * + * @return EXP before the change + */ + public double getOldXP() { + return oldExp; + } + + /** + * Legacy-style alias for the projected EXP value. + * + * @return EXP after the change preview + */ + public double getNewXP() { + return newExp; + } + + /** + * Returns the Bukkit handler list for this event type. + * + * @return static handler list required by the Bukkit event contract + */ + public static HandlerList getHandlerList() { + return handlerList; + } + + /** + * Returns the Bukkit handler list for this event type. + * + * @return handler list required by the Bukkit event contract + */ + @NotNull + public HandlerList getHandlers() { + return handlerList; + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/AxBoostersHook.java b/src/main/java/com/bitaspire/cyberlevels/hook/AxBoostersHook.java new file mode 100644 index 0000000..8469013 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/hook/AxBoostersHook.java @@ -0,0 +1,80 @@ +package com.bitaspire.cyberlevels.hook; + +import com.artillexstudios.axboosters.api.AxBoostersAPI; +import com.artillexstudios.axboosters.api.events.AxBoostersLoadEvent; +import com.artillexstudios.axboosters.hooks.booster.BoosterHook; +import com.artillexstudios.axboosters.users.User; +import com.artillexstudios.axboosters.users.UserList; +import com.bitaspire.cyberlevels.CyberLevels; +import net.kyori.adventure.key.Key; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; + +final class AxBoostersHook implements Hook, BoosterHook, Listener { + + private static final Key KEY = Key.key("cyberlevels2", "xp"); + + private static volatile AxBoostersHook registered; + + private final CyberLevels main; + + AxBoostersHook(CyberLevels main) { + this.main = main; + } + + @Override + public Key getKey() { + return KEY; + } + + @Override + public Material getIcon() { + return Material.EXPERIENCE_BOTTLE; + } + + @Override + public boolean isPersistent() { + return false; + } + + double getMultiplier(Player player) { + if (player == null) return 1D; + + try { + User user = UserList.getUser(player); + if (user == null) return 1D; + + float boost = user.getBoost(this); + if (Float.isNaN(boost) || boost <= 0F) return 1D; + return boost; + } catch (Throwable t) { + return 1D; + } + } + + @EventHandler + public void onAxBoostersLoad(AxBoostersLoadEvent event) { + synchronized (AxBoostersHook.class) { + if (registered != null) return; + try { + AxBoostersAPI.registerBoosterHook(main, this); + registered = this; + } catch (Throwable t) { + main.logger("&cFailed to register AxBoosters hook: " + t.getMessage()); + } + } + } + + @Override + public void register() { + main.getServer().getPluginManager().registerEvents(this, main); + } + + @Override + public void unregister() { + HandlerList.unregisterAll(this); + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/AxHoesHook.java b/src/main/java/com/bitaspire/cyberlevels/hook/AxHoesHook.java new file mode 100644 index 0000000..f4d4c44 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/hook/AxHoesHook.java @@ -0,0 +1,48 @@ +package com.bitaspire.cyberlevels.hook; + +import com.artillexstudios.axhoes.api.events.PlayerXPGainEvent; +import com.bitaspire.cyberlevels.CyberLevels; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; + +/** + * AxHoes integration. + * + *

AxHoes cancels the vanilla {@link org.bukkit.event.block.BlockBreakEvent} for tool-managed + * breaks and runs its own drop/XP pipeline, which makes a {@code BlockBreakEvent} listener + * unreliable as a trigger. Instead we listen to AxHoes' own {@link PlayerXPGainEvent}, which is + * fired once per successful break, and dispatch a single roll of the {@code axhoes-breaking} + * earn-exp source. The event carries no block reference, so per-block include/specific lists are + * ignored — configure {@code general.exp} to set the flat reward. + */ +final class AxHoesHook implements Hook, Listener { + + private final CyberLevels main; + private final HookManager manager; + + AxHoesHook(CyberLevels main, HookManager manager) { + this.main = main; + this.manager = manager; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerXPGain(PlayerXPGainEvent event) { + manager.sendExp( + event.getPlayer(), + main.cache().earnExp().getExpSources().get("axhoes-breaking"), + "" + ); + } + + @Override + public void register() { + main.getServer().getPluginManager().registerEvents(this, main); + } + + @Override + public void unregister() { + HandlerList.unregisterAll(this); + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/AxPickHook.java b/src/main/java/com/bitaspire/cyberlevels/hook/AxPickHook.java new file mode 100644 index 0000000..1a97603 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/hook/AxPickHook.java @@ -0,0 +1,48 @@ +package com.bitaspire.cyberlevels.hook; + +import com.artillexstudios.axpickaxes.api.events.PlayerXPGainEvent; +import com.bitaspire.cyberlevels.CyberLevels; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; + +/** + * AxPickaxes integration. + * + *

AxPickaxes cancels the vanilla {@link org.bukkit.event.block.BlockBreakEvent} for tool-managed + * breaks and runs its own drop/XP pipeline, which makes a {@code BlockBreakEvent} listener + * unreliable as a trigger. Instead we listen to AxPickaxes' own {@link PlayerXPGainEvent}, which + * is fired once per successful break, and dispatch a single roll of the {@code axpick-breaking} + * earn-exp source. The event carries no block reference, so per-block include/specific lists are + * ignored — configure {@code general.exp} to set the flat reward. + */ +final class AxPickHook implements Hook, Listener { + + private final CyberLevels main; + private final HookManager manager; + + AxPickHook(CyberLevels main, HookManager manager) { + this.main = main; + this.manager = manager; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerXPGain(PlayerXPGainEvent event) { + manager.sendExp( + event.getPlayer(), + main.cache().earnExp().getExpSources().get("axpick-breaking"), + "" + ); + } + + @Override + public void register() { + main.getServer().getPluginManager().registerEvents(this, main); + } + + @Override + public void unregister() { + HandlerList.unregisterAll(this); + } +} diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/HookManager.java b/src/main/java/com/bitaspire/cyberlevels/hook/HookManager.java index 97b8158..e2a046a 100644 --- a/src/main/java/com/bitaspire/cyberlevels/hook/HookManager.java +++ b/src/main/java/com/bitaspire/cyberlevels/hook/HookManager.java @@ -1,24 +1,47 @@ package com.bitaspire.cyberlevels.hook; +import com.bitaspire.libs.common.MetricsLoader; import com.bitaspire.cyberlevels.CyberLevels; import com.bitaspire.cyberlevels.level.ExpSource; import com.bitaspire.cyberlevels.user.LevelUser; -import net.zerotoil.dev.cybercore.addons.Metrics; import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; import java.util.HashSet; import java.util.Set; +/** + * Detects, initializes, and manages optional third-party integrations. + * + *

The hook manager is responsible for loading supported integrations only when their + * dependencies are present on the server. It also initializes plugin metrics and offers a shared + * helper path for integrations that need to forward EXP gains back into the active level system. + */ public class HookManager { private final Set hooks = new HashSet<>(); private final CyberLevels main; - + private AxBoostersHook axBoostersHook; + private boolean axHoesLoaded; + private boolean axPickLoaded; + private boolean globalRegistered; + private final LateHookListener lateListener = new LateHookListener(); + + /** + * Creates and eagerly loads every supported integration that is available on the server. + * + * @param main owning plugin instance + */ public HookManager(CyberLevels main) { (this.main = main).logger("&dLoading plugin hooks..."); long startTime = System.currentTimeMillis(); - new Metrics(main, 13782); + + System.setProperty("bstats.relocatecheck", "false"); + MetricsLoader.initialize(main, 13782); if (main.isEnabled("PlaceholderAPI")) { final long l = System.currentTimeMillis(); @@ -38,6 +61,20 @@ public HookManager(CyberLevels main) { main.logger("&7Loaded &eRivalPickaxes&7 plugin hook in &a" + (System.currentTimeMillis() - l) + "ms&7."); } + if (main.isEnabled("AxBoosters")) { + final long l = System.currentTimeMillis(); + axBoostersHook = new AxBoostersHook(main); + hooks.add(axBoostersHook); + main.logger("&7Loaded &eAxBoosters&7 plugin hook in &a" + (System.currentTimeMillis() - l) + "ms&7."); + } + + // AxHoes and AxPickaxes both list CyberLevels in their own softdepend, which makes Bukkit + // commonly enable them AFTER us — so a plain isEnabled() check at construction misses them. + // We try to load eagerly here when possible, then attach a PluginEnableEvent listener as a + // fallback that activates the hook the moment those plugins finish enabling. + loadAxHoesIfReady(); + loadAxPickIfReady(); + int c = hooks.size(); main.logger("&7Loaded &e" + c + "&7 plugin hook" + (c == 1 ? "" : "s") + @@ -45,35 +82,112 @@ public HookManager(CyberLevels main) { "ms&7.", ""); } + private synchronized void loadAxHoesIfReady() { + if (axHoesLoaded || !main.isEnabled("AxHoes")) return; + + long l = System.currentTimeMillis(); + AxHoesHook hook = new AxHoesHook(main, this); + hooks.add(hook); + + // Only register here if the global register() pass has already run; otherwise it'll be + // picked up by hooks.forEach(Hook::register) later. Registering twice would attach the + // listener twice and double-count every PlayerXPGainEvent. + if (globalRegistered) hook.register(); + + axHoesLoaded = true; + main.logger("&7Loaded &eAxHoes&7 plugin hook in &a" + (System.currentTimeMillis() - l) + "ms&7."); + } + + private synchronized void loadAxPickIfReady() { + if (axPickLoaded || !main.isEnabled("AxPickaxes")) return; + + long l = System.currentTimeMillis(); + AxPickHook hook = new AxPickHook(main, this); + hooks.add(hook); + + if (globalRegistered) hook.register(); + + axPickLoaded = true; + main.logger("&7Loaded &eAxPickaxes&7 plugin hook in &a" + (System.currentTimeMillis() - l) + "ms&7."); + } + void sendExp(Player player, ExpSource source, String item) { + if (source == null) return; if (main.levelSystem().checkAntiAbuse(player, source)) return; double counter = 0; + String matched = source.useSpecifics() ? source.matchSpecificKey(item) : null; - if (source.useSpecifics()) { - if (source.isInList(item, true)) counter = source.getSpecificRange(item).getRandom(); - } - else if (source.isEnabled()) { - if (source.isInList(item)) counter = source.getRange().getRandom(); - } + if (source.isEnabled() && + source.isInList(item) && + (matched == null || source.stackSpecificsWithGeneral())) + counter += source.getRange().getRandom(); + + if (matched != null) + counter += source.getSpecificRange(matched).getRandom(); if (counter == 0) return; LevelUser user = main.userManager().getUser(player); if (counter > 0) { - user.addExp(counter + "", main.cache().config().isMultiplierEvents()); + user.addExp(counter, main.cache().config().isMultiplierEvents()); return; } - user.removeExp(Math.abs(counter) + ""); + user.removeExp(Math.abs(counter)); } + /** + * Returns the multiplier currently applied to {@code player} by external boost integrations. + * + *

This combines every loaded multiplier-providing hook (currently AxBoosters) into a single + * scalar that callers can apply on top of permission-based multipliers. + * + * @param player target player; {@code null} returns 1.0 + * @return non-negative multiplier; 1.0 when no boost is active + */ + public double externalMultiplier(Player player) { + if (player == null) return 1D; + + double multiplier = 1D; + if (axBoostersHook != null) + multiplier *= axBoostersHook.getMultiplier(player); + return multiplier; + } + + /** + * Registers all loaded hooks with their respective target plugins or services. + */ public void register() { hooks.forEach(Hook::register); + // Catch plugins that enable AFTER us — primarily AxHoes and AxPickaxes, which softdepend + // on CyberLevels and therefore typically load later in the plugin enable order. + main.getServer().getPluginManager().registerEvents(lateListener, main); + globalRegistered = true; } + /** + * Unregisters all loaded hooks and clears the hook registry. + * + *

This is called during runtime shutdown so no old integration state remains attached after a + * reload. + */ public void unregister() { + HandlerList.unregisterAll(lateListener); hooks.forEach(Hook::unregister); hooks.clear(); + axHoesLoaded = false; + axPickLoaded = false; + globalRegistered = false; + } + + private final class LateHookListener implements Listener { + + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + String name = event.getPlugin().getName(); + if ("AxHoes".equals(name)) loadAxHoesIfReady(); + else if ("AxPickaxes".equals(name)) loadAxPickIfReady(); + } } } diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/PlaceholderAPI.java b/src/main/java/com/bitaspire/cyberlevels/hook/PlaceholderAPI.java index 08c80e3..dc11ad4 100644 --- a/src/main/java/com/bitaspire/cyberlevels/hook/PlaceholderAPI.java +++ b/src/main/java/com/bitaspire/cyberlevels/hook/PlaceholderAPI.java @@ -47,14 +47,16 @@ private String getLeaderboard(OfflinePlayer player, String type, String position return "invalid number"; } - if (position < 1 || position > 10) - return "out of bounds"; + int maxPos = main.cache().config().getLeaderboardMaxPositions(); + if (position < 1 || position > maxPos) + return "out of bounds (max: " + maxPos + ")"; LevelSystem system = main.levelSystem(); LevelUser user = system.getLeaderboard().getTopPlayer(position); Lang.LeaderboardKeys keys = main.cache().lang().leaderboardKeys(); - String value = user == null ? keys.getLoadingName() : keys.getNoPlayerName(); + String value = system.getLeaderboard().isUpdating() ? + keys.getLoadingName() : keys.getNoPlayerName(); if (user != null) { switch (type.toLowerCase()) { @@ -73,19 +75,19 @@ private String getLeaderboard(OfflinePlayer player, String type, String position } } - return main.core().textSettings() - .colorize(player instanceof Player ? (Player) player : null, value); + return main.library().colorize(player instanceof Player ? (Player) player : null, value); } @Override public String onRequest(OfflinePlayer player, @NotNull String identifier) { - if (!player.isOnline()) return null; + if (player == null || !player.isOnline()) return null; LevelSystem system = main.levelSystem(); switch (identifier.toLowerCase()) { case "level_maximum": return system.getMaxLevel() + ""; case "exp_minimum": return system.getStartExp() + ""; + case "experience_minimum": return system.getStartExp() + ""; case "level_minimum": return system.getStartLevel() + ""; } @@ -98,7 +100,7 @@ public String onRequest(OfflinePlayer player, @NotNull String identifier) { } } - LevelUser user = main.userManager().getUser((Player) player); + LevelUser user = main.userManager().getUser(player.getUniqueId()); if (user == null) return "0"; switch (identifier.toLowerCase()) { @@ -106,21 +108,27 @@ public String onRequest(OfflinePlayer player, @NotNull String identifier) { return String.valueOf(user.getLevel()); case "player_level_next": + case "player_next_level": return (Math.min(user.getLevel() + 1, system.getMaxLevel())) + ""; case "player_exp": + case "player_experience": return system.formatNumber(user.getExp()); case "player_exp_required": + case "player_experience_required": return system.formatNumber(user.getRequiredExp()); case "player_exp_remaining": + case "player_experience_remaining": return system.formatNumber(user.getRemainingExp()); case "player_exp_progress_bar": - return main.core().textSettings().colorize(user.getProgressBar()); + case "player_experience_progress_bar": + return main.library().colorize(user.getProgressBar()); case "player_exp_percent": + case "player_experience_percent": return user.getPercent(); } diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/RivalHoesHook.java b/src/main/java/com/bitaspire/cyberlevels/hook/RivalHoesHook.java index de3ec5c..4ea4b67 100644 --- a/src/main/java/com/bitaspire/cyberlevels/hook/RivalHoesHook.java +++ b/src/main/java/com/bitaspire/cyberlevels/hook/RivalHoesHook.java @@ -2,6 +2,7 @@ import com.bitaspire.cyberlevels.CyberLevels; import com.bitaspire.cyberlevels.cache.AntiAbuse; +import com.bitaspire.cyberlevels.cache.BlockExpKeys; import me.rivaldev.harvesterhoes.api.events.RivalBlockBreakEvent; import org.bukkit.block.Block; import org.bukkit.block.data.Ageable; @@ -47,7 +48,7 @@ private void onRivalBlockBreak(RivalBlockBreakEvent event) { manager.sendExp( player, main.cache().earnExp().getExpSources().get("rivalhh-breaking"), - block.getType().toString() + BlockExpKeys.blockKey(block, main.serverVersion()) ); } diff --git a/src/main/java/com/bitaspire/cyberlevels/hook/RivalPickHook.java b/src/main/java/com/bitaspire/cyberlevels/hook/RivalPickHook.java index fe74dda..0d883d3 100644 --- a/src/main/java/com/bitaspire/cyberlevels/hook/RivalPickHook.java +++ b/src/main/java/com/bitaspire/cyberlevels/hook/RivalPickHook.java @@ -2,6 +2,7 @@ import com.bitaspire.cyberlevels.CyberLevels; import com.bitaspire.cyberlevels.cache.AntiAbuse; +import com.bitaspire.cyberlevels.cache.BlockExpKeys; import me.rivaldev.pickaxes.api.events.RivalPickaxesBlockBreakEvent; import org.bukkit.block.Block; import org.bukkit.block.data.Ageable; @@ -45,7 +46,7 @@ public void onRivalBlockBreak(RivalPickaxesBlockBreakEvent event) { manager.sendExp( player, main.cache().earnExp().getExpSources().get("rivalpick-breaking"), - block.getType().toString() + BlockExpKeys.blockKey(block, main.serverVersion()) ); } diff --git a/src/main/java/com/bitaspire/cyberlevels/level/AntiAbuse.java b/src/main/java/com/bitaspire/cyberlevels/level/AntiAbuse.java index b202efa..9dddb55 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/AntiAbuse.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/AntiAbuse.java @@ -2,9 +2,7 @@ import com.bitaspire.cyberlevels.CyberLevels; import lombok.Getter; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -12,112 +10,123 @@ import java.util.*; /** - * Represents an anti-abuse mechanism for managing cooldowns and limiters in a leveling system. + * Public view of one anti-abuse module configured in CyberLevels. * - *

This interface provides methods to check and manage cooldowns, limiters, and world restrictions - * for players based on experience sources. + *

An anti-abuse module can limit how frequently a player is rewarded, cap how many rewards they + * can receive within a time window, and restrict operation to specific worlds. Implementations are + * expected to be stateful because they track per-player counters and cooldowns across gameplay. */ public interface AntiAbuse { /** - * Checks if the cooldown feature is enabled. - * @return true if cooldown is enabled, false otherwise + * Indicates whether cooldown enforcement is enabled for this module. + * + * @return {@code true} when cooldown checks should run */ boolean isCooldownEnabled(); /** - * Gets the cooldown time in milliseconds. - * @return the cooldown time + * Returns the configured cooldown duration in seconds. + * + * @return cooldown length as configured by the module */ int getCooldownTime(); /** - * Gets the remaining cooldown time for a player in milliseconds. + * Returns the remaining cooldown for a player. * - * @param player the player to check - * @return the remaining cooldown time, or 0 if no cooldown is active + * @param player player whose cooldown should be checked + * @return remaining cooldown in seconds, or {@code 0} when none is active */ int getCooldownLeft(Player player); /** - * Resets all cooldowns for all players. + * Clears every tracked cooldown entry in this module. */ void resetCooldowns(); /** - * Resets the cooldown for a specific player. - * @param player the player whose cooldown is to be reset + * Clears the tracked cooldown for a specific player. + * + * @param player player whose cooldown should be removed */ void resetCooldown(Player player); /** - * Checks if the limiter feature is enabled. - * @return true if limiter is enabled, false otherwise + * Indicates whether limiter enforcement is enabled for this module. + * + * @return {@code true} when limiter checks should run */ boolean isLimiterEnabled(); /** - * Gets the maximum amount allowed by the limiter. - * @return the limiter amount + * Returns the configured limiter budget. + * + * @return maximum number of allowed reward operations before the limiter blocks them */ long getLimiterAmount(); /** - * Gets the current amount used by the limiter for a specific player. + * Returns the remaining limiter budget for a player. * - * @param player the player to check - * @return the current amount used by the limiter + * @param player player whose limiter state should be checked + * @return remaining allowed operations before the limiter triggers */ int getLimiter(Player player); /** - * Resets all limiters for all players. + * Clears the limiter state for every tracked player. */ void resetLimiters(); /** - * Resets the limiter for a specific player. - * @param player the player whose limiter is to be reset + * Clears the limiter state for a specific player. + * + * @param player player whose limiter should be removed */ void resetLimiter(Player player); /** - * Gets the timer associated with this anti-abuse mechanism. - * @return the Timer instance + * Returns the scheduled reset timer associated with this module. + * + * @return timer controlling automatic limiter resets, or {@code null} when none is configured */ Timer getTimer(); /** - * Cancels the timer and purges any scheduled tasks. + * Stops and purges the configured reset timer, if any. */ void cancelTimer(); /** - * Checks if world restrictions are enabled. - * @return true if world restrictions are enabled, false otherwise + * Indicates whether this module restricts itself to a configured world list. + * + * @return {@code true} when world filtering is enabled */ boolean isWorldsEnabled(); /** - * Checks if the world list is a whitelist. - * @return true if the world list is a whitelist, false if it's a blacklist + * Indicates how the configured world list should be interpreted. + * + * @return {@code true} when the world list acts as a whitelist, otherwise a blacklist */ boolean isWorldsWhitelist(); /** - * Checks if a player is limited in a specific experience source. - * - * @param player the player to check - * @param source the experience source + * Evaluates whether this module should block a reward attempt. * - * @return true if the player is limited, false otherwise + * @param player player attempting to receive EXP + * @param source EXP source being processed + * @return {@code true} when the action should be denied by this module */ boolean isLimited(Player player, ExpSource source); /** - * Timer class to handle scheduled resets for anti-abuse limiters. + * Scheduler helper used by anti-abuse modules that need periodic limiter resets. * - *

This class parses a formatted string to determine reset intervals and schedules tasks accordingly. + *

The timer accepts the date/time expression format used in the plugin configuration, parses + * the first execution time plus any repeat intervals, and then re-schedules itself after each + * completed reset. */ class Timer { @@ -131,17 +140,17 @@ class Timer { private String[] intervals; /** - * The next reset epoch time in milliseconds. - * */ + * Absolute epoch time, in milliseconds, when the next limiter reset is expected to happen. + */ @Getter private long resetEpochTime = Long.MAX_VALUE; /** - * Constructs a Timer instance with the specified parameters. + * Creates a timer from the raw anti-abuse schedule string. * - * @param main the main CyberLevels instance - * @param antiAbuse the AntiAbuse instance associated with this timer - * @param unformatted the unformatted string representing the reset schedule + * @param main owning plugin instance used for scheduling callbacks + * @param antiAbuse anti-abuse module whose limiters should be reset + * @param unformatted raw schedule expression from the configuration */ public Timer(CyberLevels main, AntiAbuse antiAbuse, String unformatted) { this.main = main; @@ -157,14 +166,13 @@ public Timer(CyberLevels main, AntiAbuse antiAbuse, String unformatted) { } /** - * Starts the timer asynchronously. + * Starts the scheduler asynchronously. + * + *

The actual parsing and scheduling work is performed off the main thread, while the + * limiter reset callback itself is marshalled back to the scheduler supplied by the plugin. */ public void start() { - new BukkitRunnable() { - public void run() { - startScheduler(false); - } - }.runTaskAsynchronously(main); + main.scheduler().runTaskAsynchronously(() -> startScheduler(false)); } private void startScheduler(boolean cancelTimer) { @@ -192,7 +200,7 @@ private void run(long intervalMS) { } /** - * Cancels the timer and purges any scheduled tasks. + * Cancels the currently scheduled timer task and purges the underlying timer queue. */ public void purge() { timer.cancel(); @@ -358,14 +366,8 @@ private class TimedTask extends TimerTask { public void run() { if (main == null || !main.isEnabled()) return; - Bukkit.getScheduler().runTask(main, antiAbuse::resetLimiters); - - new BukkitRunnable() { - @Override - public void run() { - startScheduler(true); - } - }.runTaskLaterAsynchronously(main, 20L); + main.scheduler().runTask(antiAbuse::resetLimiters); + main.scheduler().runTaskLaterAsynchronously(() -> startScheduler(true), 20L); } } diff --git a/src/main/java/com/bitaspire/cyberlevels/level/ExpSource.java b/src/main/java/com/bitaspire/cyberlevels/level/ExpSource.java index 5719ed7..669538a 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/ExpSource.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/ExpSource.java @@ -2,183 +2,230 @@ import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; /** - * Represents a source of experience points in a leveling system. + * Describes one configurable source of EXP gains or losses. * - *

This interface provides methods to manage experience sources, including - * categories, names, intervals, ranges, inclusion/exclusion lists, permissions, - * and experience calculations. + *

An EXP source corresponds to an entry from {@code earn-exp.yml}. Depending on configuration, + * a source can represent a flat reward range, a permission-based source, or a map of specific + * values such as block states, item names, players, or entity types. */ public interface ExpSource { /** - * Gets the category of the experience source. - * @return the category as a non-null string + * Returns the unique configuration category of this source. + * + * @return source category id */ @NotNull String getCategory(); /** - * Gets the name of the experience source. - * @return the name as a non-null string + * Returns the specific subsection name used by this source, when applicable. + * + * @return specific subsection identifier, or an empty string when unused */ @NotNull String getName(); /** - * Checks if the experience source is enabled. - * @return true if enabled, false otherwise + * Indicates whether the source's general reward mode is enabled. + * + * @return {@code true} when the base range is active */ boolean isEnabled(); /** - * Gets the interval for the experience source. - * @return the interval as an integer + * Returns the configured interval for timer-based sources. + * + * @return source interval in seconds or ticks depending on the implementation context */ int getInterval(); /** - * Gets the range of experience points for the source. - * @return a non-null Range object representing the experience range + * Returns the general reward range for this source. + * + * @return numeric range used when the source is not in specific mode */ @NotNull Range getRange(); /** - * Checks if the experience source includes specific items or actions. - * @return true if it includes, false otherwise + * Indicates whether the source uses an include/exclude list. + * + * @return {@code true} when include-list filtering is enabled */ boolean includes(); /** - * Checks if the experience source operates as a whitelist. - * @return true if it is a whitelist, false if it is a blacklist + * Indicates how the include list should be interpreted. + * + * @return {@code true} when the include list acts as a whitelist, otherwise a blacklist */ boolean isWhitelist(); /** - * Gets the list of items or actions included in the experience source. - * @return a non-null list of strings representing the included items/actions + * Returns the configured include-list entries for this source. + * + * @return include-list values in their configured order */ @NotNull List getIncludeList(); /** - * Checks if the experience source uses specific values. - * @return true if it uses specific values, false otherwise + * Indicates whether the source uses a specific-value map instead of a single general range. + * + * @return {@code true} when specific values are enabled */ boolean useSpecifics(); /** - * Gets the list of specific values used by the experience source. - * @return a non-null list of strings representing the specific values + * Indicates whether matching specific values should be added to the general reward. + * + *

When disabled, a matching specific value replaces the general reward for that runtime + * value. Values that do not match a specific entry may still receive the general reward. + * + * @return {@code true} when specific rewards stack with the general reward + */ + boolean stackSpecificsWithGeneral(); + + /** + * Returns the configured specific keys for this source. + * + * @return specific map keys available for matching */ @NotNull List getSpecificList(); /** - * Checks if a given value is in the include/exclude list of the experience source. + * Checks whether a runtime value is accepted by this source. * - * @param value the value to check - * @param specific whether to check in the specific list - * @return true if the value is in the list, false otherwise + * @param value runtime value to evaluate + * @param specific whether the lookup should use the specific-value map instead of the general + * include/exclude list + * @return {@code true} when the value matches the requested lookup mode */ boolean isInList(String value, boolean specific); /** - * Checks if a given value is in the include/exclude list of the experience source. + * Resolves which configured specific key should be used for a runtime value. * - * @param value the value to check - * @return true if the value is in the list, false otherwise + *

Implementations may return exact matches, compatibility fallbacks, or {@code null} when no + * specific key is suitable. + * + * @param value runtime token such as a material, block-state key, player name, or entity type + * @return matching configured key, or {@code null} when none applies + */ + @Nullable + default String matchSpecificKey(String value) { + return null; + } + + /** + * Convenience overload for checking the general include/exclude rules of this source. + * + * @param value runtime value to evaluate + * @return {@code true} when the value matches the general include rules */ default boolean isInList(String value) { return isInList(value, false); } /** - * Checks if a player has permission for the experience source. - * - * @param player the player to check - * @param specific whether to check for specific permissions + * Checks whether a player satisfies the source's permission rules. * - * @return true if the player has permission, false otherwise + * @param player player to evaluate + * @param specific whether to test the specific-value permission map instead of the general + * include list + * @return {@code true} when the player matches the configured permission logic */ boolean hasPermission(Player player, boolean specific); /** - * Checks if a player has permission for the experience source. + * Convenience overload for checking the source's general permission rules. * - * @param player the player to check - * @return true if the player has permission, false otherwise + * @param player player to evaluate + * @return {@code true} when the player matches the general permission logic */ default boolean hasPermission(Player player) { return hasPermission(player, false); } /** - * Gets a specific range of experience points based on a given value. + * Returns the reward range associated with a specific configured key. * - * @param value the value to determine the specific range - * @return a Range object representing the specific experience range + * @param value specific key to resolve + * @return reward range for that key */ Range getSpecificRange(String value); /** - * Calculates the experience points for a given string input. + * Calculates EXP based on a partial string match operation. + * + *

This is primarily used by free-form sources such as chat, enchanting, and brewing where + * one runtime string may match multiple configured fragments. * - * @param string the input string to calculate experience from - * @return the calculated experience points as a double + * @param string runtime text to inspect + * @return resulting EXP amount after partial-match evaluation */ double getPartialMatchesExp(String string); /** - * Gets the registrable object associated with the experience source. - * @return a non-null Registrable object + * Returns the lifecycle adapter used to register and unregister this source at runtime. + * + * @return registrable responsible for activating the source */ @NotNull Registrable getRegistrable(); /** - * Represents a numerical range with minimum and maximum values, - * and provides a method to get a random value within that range. + * Represents a numeric reward range. + * + *

Implementations may represent a fixed value, a min/max interval, or any custom random + * generation strategy compatible with CyberLevels. */ interface Range { /** - * Gets the minimum value of the range. - * @return the minimum value as a double + * Returns the minimum possible value of the range. + * + * @return lower bound */ double getMin(); /** - * Gets the maximum value of the range. - * @return the maximum value as a double + * Returns the maximum possible value of the range. + * + * @return upper bound */ double getMax(); /** - * Gets a random value within the range. - * @return a random double value between min and max + * Produces a value from the range according to the implementation's selection rules. + * + * @return generated reward value */ double getRandom(); } /** - * Represents an object that can be registered and unregistered, - * typically for event handling or similar purposes. + * Lifecycle adapter used to activate and deactivate an EXP source. + * + *

This abstraction lets sources register Bukkit listeners, scheduler tasks, or any other + * runtime resource without exposing those details to the outer cache. */ interface Registrable { /** - * Registers the object, enabling its functionality. + * Activates the source and allocates any required runtime resources. */ void register(); /** - * Unregisters the object, disabling its functionality. + * Deactivates the source and releases any runtime resources it owns. */ void unregister(); } diff --git a/src/main/java/com/bitaspire/cyberlevels/level/Formula.java b/src/main/java/com/bitaspire/cyberlevels/level/Formula.java index 75a95b1..ac9febb 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/Formula.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/Formula.java @@ -1,32 +1,36 @@ package com.bitaspire.cyberlevels.level; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import java.util.UUID; /** - * Represents a mathematical formula used for calculating experience points or levels. + * Represents a compiled formula used by the level system to calculate required experience. * - *

This interface provides methods to retrieve the formula as a string and to evaluate - * the formula based on a player's attributes. + *

Implementations usually wrap a raw expression from {@code levels.yml}, optionally enriched + * with player-specific placeholders, and expose both the original string form and the evaluated + * numeric result. * - * @param the numeric type used for calculations + * @param numeric type produced by the active level engine */ public interface Formula { /** - * Retrieves the formula as a string representation. - * @return the formula in string format + * Returns the original textual expression used to build this formula. + * + * @return expression string as configured by the plugin */ @NotNull String getAsString(); /** - * Evaluates the formula based on the provided player's attributes. + * Evaluates the formula for a specific player context. + * + *

The supplied UUID allows implementations to resolve per-player placeholders before + * performing the final numeric evaluation. * - * @param player the player whose attributes are used for evaluation - * @return the result of the formula evaluation + * @param uuid player UUID whose data should be used during evaluation + * @return evaluated numeric result */ @NotNull N evaluate(UUID uuid); diff --git a/src/main/java/com/bitaspire/cyberlevels/level/Leaderboard.java b/src/main/java/com/bitaspire/cyberlevels/level/Leaderboard.java index 74b7b83..6c44c74 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/Leaderboard.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/Leaderboard.java @@ -7,53 +7,60 @@ import java.util.List; /** - * Represents a leaderboard that tracks and manages player rankings based on their levels and experience points. + * Read-only view of the ranking system maintained by CyberLevels. * - *

This interface provides methods to check if the leaderboard is updating, to update the leaderboard, - * to retrieve the top players, and to check a player's position on the leaderboard. + *

The leaderboard is built from the currently known user data and is typically refreshed + * asynchronously. Consumers can inspect the cached ranking, query individual positions, or trigger + * a new refresh when they need the latest standings. * - * @param the numeric type used for experience points and calculations + * @param numeric type used by the active level engine */ public interface Leaderboard { /** - * Checks if the leaderboard is currently updating. - * @return true if the leaderboard is updating, false otherwise + * Indicates whether a refresh operation is currently in progress. + * + * @return {@code true} while the leaderboard is being recomputed */ boolean isUpdating(); /** - * Updates the leaderboard asynchronously. + * Requests a refresh of the cached leaderboard ordering. + * + *

Implementations are free to perform the actual work asynchronously when ranking data + * needs to be loaded or sorted outside of the main server thread. */ void update(); /** - * Retrieves a list of the top ten players on the leaderboard. - * @return a list of the top ten LevelUser objects + * Returns the cached top players up to the configured leaderboard size limit. + * + * @return ordered list starting at rank {@code 1} */ @NotNull List> getTopTenPlayers(); /** - * Retrieves the player at the specified position on the leaderboard. + * Returns the cached player entry at a specific leaderboard position. * - * @param position the position of the player to retrieve (1-based index) - * @return the LevelUser object at the specified position, or null if not found + * @param position one-based leaderboard position + * @return ranked user at that position, or {@code null} when unavailable */ LevelUser getTopPlayer(int position); /** - * Checks the position of a specific user on the leaderboard. + * Resolves the cached position of a user currently known to the leaderboard. * - * @param user the user whose position to check - * @return the position of the user on the leaderboard (1-based index), or -1 if not found + * @param user user whose position should be checked + * @return one-based position, or {@code -1} when the user is not ranked */ int checkPosition(LevelUser user); /** - * Checks the position of a specific player on the leaderboard. - * @param player the player whose position to check - * @return the position of the player on the leaderboard (1-based index), or -1 if not found + * Convenience overload that resolves a position from a live Bukkit player. + * + * @param player player whose position should be checked + * @return one-based position, or {@code -1} when the player is not ranked */ int checkPosition(Player player); } diff --git a/src/main/java/com/bitaspire/cyberlevels/level/LevelSystem.java b/src/main/java/com/bitaspire/cyberlevels/level/LevelSystem.java index 8598f16..6a3a056 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/LevelSystem.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/LevelSystem.java @@ -5,93 +5,113 @@ import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; /** - * Represents a level system with various configurations and functionalities. + * Central API for the active CyberLevels progression engine. * - *

This interface provides methods to manage levels, experience points, formulas, - * leaderboards, anti-abuse mechanisms, and placeholders. + *

The level system ties together numeric operations, required-EXP formulas, leaderboard + * calculation, anti-abuse checks, placeholder replacement, and formatting helpers. Most plugin and + * integration code should interact with this interface rather than with a concrete implementation. * - * @param the numeric type used for experience points and calculations + * @param numeric type used by the active level engine */ public interface LevelSystem { /** - * Gets the starting level for the level system. - * @return the starting level + * Returns the configured starting level assigned to newly created users. + * + * @return first level in the progression curve */ long getStartLevel(); /** - * Gets the starting experience points for the level system. - * @return the starting experience points + * Returns the configured starting EXP assigned to newly created users. + * + * @return starting EXP value */ int getStartExp(); /** - * Gets the maximum level for the level system. - * @return the maximum level + * Returns the highest level reachable through this progression system. + * + * @return configured max level */ long getMaxLevel(); /** - * Gets the operator used for calculations in the level system. - * @return the operator + * Returns the numeric operator used by this engine. + * + * @return arithmetic abstraction for the active numeric backend */ @NotNull Operator getOperator(); /** - * Gets the default formula used for experience calculations. - * @return the default formula + * Returns the fallback formula used when no per-level override exists. + * + * @return default required-EXP formula */ @NotNull Formula getFormula(); /** - * Gets a custom formula for the specified level. + * Returns the custom formula override for a specific level, if one exists. * - * @param level the level number - * @return the custom formula for the specified level, or null if no custom formula is defined + * @param level level whose override should be resolved + * @return override formula, or {@code null} when the default formula should be used */ Formula getCustomFormula(long level); + /** + * Calculates the required EXP for a specific level and player context. + * + * @param level level whose requirement should be evaluated + * @param uuid player UUID used for placeholder-aware formulas + * @return required EXP for the supplied level + */ @NotNull N getRequiredExp(long level, UUID uuid); + /** + * Returns every reward configured for the supplied level. + * + * @param level level whose rewards should be retrieved + * @return ordered list of rewards for that level + */ @NotNull List getRewards(long level); /** - * Gets the leaderboard associated with the level system. - * @return the leaderboard + * Returns the leaderboard maintained by this level system. + * + * @return active leaderboard instance */ @NotNull Leaderboard getLeaderboard(); /** - * Gets a map of experience sources defined in the level system. - * @return a map of experience sources + * Returns the configured EXP sources available to the level system. + * + * @return EXP sources keyed by configuration id */ @NotNull Map getExpSources(); /** - * Gets a map of anti-abuse mechanisms defined in the level system. - * @return a map of anti-abuse mechanisms + * Returns the configured anti-abuse modules currently enforced by the level system. + * + * @return anti-abuse modules keyed by configuration id */ @NotNull Map getAntiAbuses(); /** - * Checks if the specified player is limited by any anti-abuse mechanism for the given experience source. + * Checks every configured anti-abuse module for a possible restriction. * - * @param player the player to check - * @param source the experience source - * - * @return true if the player is limited, false otherwise + * @param player player attempting to gain or lose EXP + * @param source EXP source being processed + * @return {@code true} when any anti-abuse module blocks the action */ default boolean checkAntiAbuse(Player player, ExpSource source) { for (AntiAbuse abuse : getAntiAbuses().values()) @@ -101,70 +121,68 @@ default boolean checkAntiAbuse(Player player, ExpSource source) { } /** - * Rounds the given amount of experience points according to the level system's rules. + * Applies the engine's configured rounding rules to a numeric value. * - * @param amount the amount of experience points to round - * @return the rounded amount of experience points + * @param amount numeric value to round + * @return rounded value in the engine's native number type */ @NotNull N round(N amount); /** - * Rounds the given amount of experience points and returns it as a string representation. + * Applies rounding and formats the result as text. * - * @param amount the amount of experience points to round - * @return the rounded amount of experience points as a string + * @param amount numeric value to round and format + * @return rounded value rendered as a string */ @NotNull String roundString(N amount); /** - * Rounds the given amount of experience points and returns it as a double representation. + * Applies rounding and returns the result as a primitive {@code double}. * - * @param amount the amount of experience points to round - * @return the rounded amount of experience points as a double + * @param amount numeric value to round + * @return rounded value converted to {@code double} */ double roundDouble(N amount); /** - * Formats the provided numeric value according to the level system rounding rules. + * Formats an arbitrary numeric value using the same rules applied to player EXP values. * - * @param value the numeric value to format - * @return the formatted numeric value as a string + * @param value numeric value to format + * @return formatted number string */ @NotNull String formatNumber(Number value); /** - * Generates a progress bar string representing the player's progress towards the next level. - * - * @param exp the current experience points of the player - * @param requiredExp the experience points required to reach the next level + * Builds the configured progress bar for a pair of current and required EXP values. * - * @return a string representing the progress bar + * @param exp current EXP value + * @param requiredExp EXP required for the next level + * @return formatted progress bar string */ @NotNull String getProgressBar(N exp, N requiredExp); /** - * Calculates the percentage of experience points the player has towards the next level. + * Calculates a formatted completion percentage for the supplied EXP values. * - * @param exp the current experience points of the player - * @param requiredExp the experience points required to reach the next level - * - * @return a string representing the percentage of experience points + * @param exp current EXP value + * @param requiredExp EXP required for the next level + * @return percentage string ready for display */ @NotNull String getPercent(N exp, N requiredExp); /** - * Replaces placeholders in the given string with actual values based on the player's data. - * - * @param string the string containing placeholders - * @param uuid the UUID of the player - * @param safeForFormula indicates whether the replacement should be safe for formula usage + * Replaces CyberLevels placeholders inside a string using player data and system state. * - * @return the string with placeholders replaced by actual values + * @param string source string containing placeholders + * @param uuid player UUID used for lookup context + * @param safeForFormula whether replacements should avoid formatting that could break formula + * evaluation + * @return string with placeholders resolved */ @NotNull String replacePlaceholders(String string, UUID uuid, boolean safeForFormula); diff --git a/src/main/java/com/bitaspire/cyberlevels/level/Operator.java b/src/main/java/com/bitaspire/cyberlevels/level/Operator.java index 392df04..d2a49a0 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/Operator.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/Operator.java @@ -3,141 +3,137 @@ import java.math.RoundingMode; /** - * Represents a mathematical operator for performing arithmetic operations on numbers. + * Abstraction over the numeric engine used by CyberLevels. * - * @param the numeric type used for calculations + *

The plugin can run either on a lightweight {@code double}-based implementation or on a + * higher-precision big-decimal implementation. This interface hides those differences behind a + * shared arithmetic API so the rest of the level system can stay generic. + * + * @param numeric type handled by the implementation */ public interface Operator { /** - * Returns the zero value of the numeric type. - * @return the zero value + * Returns the additive identity of the numeric type. + * + * @return zero value for the current numeric engine */ N zero(); /** - * Converts a string representation of a number to the numeric type. - * - * @param value the string representation of the number + * Parses a textual numeric value into the engine's native number type. * - * @return the numeric value - * @throws NumberFormatException if the string cannot be parsed to a number + * @param value textual number to parse + * @return parsed numeric value + * @throws NumberFormatException when the input cannot be parsed */ N valueOf(String value) throws NumberFormatException; /** - * Converts a double value to the numeric type. + * Converts a primitive {@code double} into the engine's native number type. * - * @param value the double value - * @return the numeric value + * @param value primitive value to convert + * @return converted numeric value */ N fromDouble(double value); /** - * Adds two numeric values. + * Adds two values using the engine's precision rules. * - * @param a the first numeric value - * @param b the second numeric value - * - * @return the sum of a and b + * @param a left operand + * @param b right operand + * @return addition result */ N add(N a, N b); /** - * Subtracts the second numeric value from the first. - * - * @param a the first numeric value - * @param b the second numeric value + * Subtracts one value from another using the engine's precision rules. * - * @return the result of a - b + * @param a left operand + * @param b right operand + * @return subtraction result */ N subtract(N a, N b); /** - * Multiplies two numeric values. - * - * @param a the first numeric value - * @param b the second numeric value + * Multiplies two values using the engine's precision rules. * - * @return the product of a and b + * @param a left operand + * @param b right operand + * @return multiplication result */ N multiply(N a, N b); /** - * Divides the first numeric value by the second. + * Divides one value by another using the engine's default division strategy. * - * @param a the first numeric value - * @param b the second numeric value - * - * @return the result of a / b - * @throws ArithmeticException if division by zero occurs + * @param a dividend + * @param b divisor + * @return division result + * @throws ArithmeticException when the divisor is zero */ N divide(N a, N b); /** - * Divides the first numeric value by the second with specified scale and rounding mode. - * - * @param a the first numeric value - * @param b the second numeric value - * @param scale the number of digits to the right of the decimal point - * @param mode the rounding mode to apply - * - * @return the result of a / b with specified scale and rounding - * @throws ArithmeticException if division by zero occurs + * Divides one value by another with an explicit scale and rounding policy. + * + * @param a dividend + * @param b divisor + * @param scale amount of fractional precision to keep + * @param mode rounding mode to apply when needed + * @return scaled division result + * @throws ArithmeticException when the divisor is zero */ N divide(N a, N b, int scale, RoundingMode mode); /** - * Compares two numeric values. + * Compares two values according to the engine's natural ordering. * - * @param a the first numeric value - * @param b the second numeric value - * - * @return a negative integer, zero, or a positive integer as a is less than, equal to, or greater than b + * @param a first value + * @param b second value + * @return negative, zero, or positive depending on the ordering of {@code a} and {@code b} */ int compare(N a, N b); /** - * Returns the minimum of two numeric values. - * - * @param a the first numeric value - * @param b the second numeric value + * Returns the smaller of the two supplied values. * - * @return the minimum of a and b + * @param a first value + * @param b second value + * @return smaller value */ N min(N a, N b); /** - * Returns the maximum of two numeric values. - * - * @param a the first numeric value - * @param b the second numeric value + * Returns the larger of the two supplied values. * - * @return the maximum of a and b + * @param a first value + * @param b second value + * @return larger value */ N max(N a, N b); /** - * Returns the absolute value of the numeric value. + * Returns the absolute value of the supplied number. * - * @param a the numeric value - * @return the absolute value of a + * @param a value to normalize + * @return absolute value */ N abs(N a); /** - * Returns the negation of the numeric value. + * Returns the additive inverse of the supplied number. * - * @param a the numeric value - * @return the negation of a + * @param a value to negate + * @return negated value */ N negate(N a); /** - * Converts the numeric value to its string representation. + * Formats the supplied value using the engine's canonical string representation. * - * @param value the numeric value - * @return the string representation of the numeric value + * @param value numeric value to format + * @return engine-specific string form */ String toString(N value); } diff --git a/src/main/java/com/bitaspire/cyberlevels/level/Reward.java b/src/main/java/com/bitaspire/cyberlevels/level/Reward.java index bb705c7..953f931 100644 --- a/src/main/java/com/bitaspire/cyberlevels/level/Reward.java +++ b/src/main/java/com/bitaspire/cyberlevels/level/Reward.java @@ -3,31 +3,39 @@ import org.bukkit.entity.Player; /** - * Represents a reward that can be given to a player, including messages, commands, items, and sounds. + * Represents a level reward that can be delivered to a player. + * + *

A reward may contain one or more side effects such as console commands, player messages, and + * sound playback. Implementations are free to no-op on individual aspects when that part of the + * reward is not configured. */ public interface Reward { /** - * Sends reward messages to the specified player. - * @param player the player to send messages to + * Sends the message portion of the reward to the target player. + * + * @param player player who should receive the reward message output */ void sendMessages(Player player); /** - * Executes reward commands for the specified player. - * @param player the player to execute commands for + * Executes the command portion of the reward for the target player. + * + * @param player player whose context should be used for reward commands */ void executeCommands(Player player); /** - * Plays a reward sound for the specified player. - * @param player the player to play the sound for + * Plays the configured sound portion of the reward, if any. + * + * @param player player who should hear the reward sound */ void playSound(Player player); /** - * Gives all aspects of the reward (messages, commands, sound) to the specified player. - * @param player the player to give the reward to + * Delivers every configured aspect of the reward in the default order used by CyberLevels. + * + * @param player player who should receive the reward */ default void giveAll(Player player) { executeCommands(player); diff --git a/src/main/java/com/bitaspire/cyberlevels/listener/Listeners.java b/src/main/java/com/bitaspire/cyberlevels/listener/Listeners.java index e6b9e44..0b8c007 100644 --- a/src/main/java/com/bitaspire/cyberlevels/listener/Listeners.java +++ b/src/main/java/com/bitaspire/cyberlevels/listener/Listeners.java @@ -1,6 +1,7 @@ package com.bitaspire.cyberlevels.listener; import com.bitaspire.cyberlevels.CyberLevels; +import com.bitaspire.cyberlevels.utility.SpigotUpdateChecker; import org.bukkit.Bukkit; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; @@ -12,17 +13,29 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.metadata.FixedMetadataValue; -import org.bukkit.scheduler.BukkitRunnable; import java.util.HashSet; import java.util.List; import java.util.Set; +/** + * Bundles the core Bukkit listeners used by CyberLevels. + * + *

This class groups the plugin's always-on listeners, such as player load/save hooks and the + * piston metadata fix used by the anti-abuse system. The actual listener instances are created in + * the constructor and then registered or unregistered as one unit during runtime startup and + * shutdown. + */ public class Listeners { private final Set listeners = new HashSet<>(); private final CyberLevels main; + /** + * Creates the listener bundle for the current plugin runtime. + * + * @param main owning plugin instance + */ public Listeners(CyberLevels main) { this.main = main; @@ -30,6 +43,7 @@ public Listeners(CyberLevels main) { @EventHandler private void onJoin(PlayerJoinEvent event) { main.userManager().loadPlayer(event.getPlayer()); + SpigotUpdateChecker.deliverPendingOpChatOnJoin(main, event.getPlayer()); } @EventHandler @@ -50,28 +64,31 @@ private void onPistonRetract(BlockPistonRetractEvent event) { } }; - register(); } + /** + * Registers every bundled listener with Bukkit. + */ public void register() { listeners.forEach(ExpListener::register); } + /** + * Unregisters every bundled listener from Bukkit. + */ public void unregister() { listeners.forEach(ExpListener::unregister); } private void fixPlacedAbuse(List blocks, BlockFace direction) { for (Block block : blocks) { - if (block.hasMetadata("CLV_PLACED")) { - (new BukkitRunnable() { - @Override - public void run() { - Block newBlock = block.getRelative(direction); - newBlock.setMetadata("CLV_PLACED", new FixedMetadataValue(main, true)); - } - }).runTaskLater(main, 1L); - } + if (!block.hasMetadata("CLV_PLACED")) continue; + + main.scheduler().runTaskLater(() -> + block.getRelative(direction).setMetadata( + "CLV_PLACED", + new FixedMetadataValue(main, true) + ), 1L); } } diff --git a/src/main/java/com/bitaspire/cyberlevels/user/Database.java b/src/main/java/com/bitaspire/cyberlevels/user/Database.java index fd4faf1..2e46519 100644 --- a/src/main/java/com/bitaspire/cyberlevels/user/Database.java +++ b/src/main/java/com/bitaspire/cyberlevels/user/Database.java @@ -7,79 +7,105 @@ import java.util.UUID; /** - * Represents a database interface for managing user data in a leveling system. + * Persistence abstraction used by CyberLevels to store user progress. * - *

This interface provides methods to connect, disconnect, and manage user data - * within the database. + *

Implementations may be backed by flat files, SQLite, MySQL, PostgreSQL, or any other storage + * mechanism supported by the plugin. The interface intentionally focuses on the lifecycle and CRUD + * operations required by the user manager. * - * @param the numeric type used for levels and experience points + * @param numeric type used by the active level engine */ public interface Database { /** - * Checks if the database is currently connected. - * @return true if connected, false otherwise + * Indicates whether the database layer is currently connected and ready for use. + * + * @return {@code true} when the persistence backend is available */ boolean isConnected(); /** - * Establishes a connection to the database. + * Establishes the underlying connection or prepares the backing storage. */ void connect(); /** - * Disconnects from the database. + * Closes the underlying connection and releases any held resources. */ void disconnect(); /** - * Checks if a user is loaded in the database. + * Checks whether the supplied user already exists in the backing store. * - * @param user the LevelUser to check - * @return true if the user is loaded, false otherwise + * @param user user whose persistence state should be checked + * @return {@code true} when a backing record already exists */ boolean isUserLoaded(LevelUser user); /** - * Adds a user to the database. + * Inserts a user into the backing store. * - * @param user the LevelUser to add - * @param defValues if true, default values will be set for the user + * @param user user to insert + * @param defValues whether default level/EXP values should be applied during insertion */ void addUser(LevelUser user, boolean defValues); /** - * Adds a user to the database with default values. - * @param user the LevelUser to add + * Inserts a user and applies the default values expected for first-time entries. + * + * @param user user to insert */ default void addUser(LevelUser user) { addUser(user, true); } /** - * Updates the user's data in the database. - * @param user the LevelUser to update + * Persists the current state of a user. + * + * @param user user whose data should be written */ void updateUser(LevelUser user); + /** + * Persists the current state of a user synchronously on the calling thread. + * + *

Implementations that do not need a dedicated synchronous path may safely delegate to + * {@link #updateUser(LevelUser)}. + * + * @param user user whose data should be written + */ + default void updateUserSync(LevelUser user) { + updateUser(user); + } + + /** + * Removes a user record from the backing store. + * + * @param uuid UUID of the player that should be deleted + */ void removeUser(UUID uuid); /** - * Retrieves a LevelUser instance for the given uuid. + * Loads or resolves a user by UUID. * - * @param uuid the UUID to retrieve the LevelUser for - * @return the LevelUser instance + * @param uuid UUID to resolve + * @return corresponding user instance */ LevelUser getUser(UUID uuid); /** - * Retrieves a LevelUser instance for the given player. + * Convenience overload that resolves a user from a live player object. * - * @param player the Player to retrieve the LevelUser for - * @return the LevelUser instance + * @param player player whose data should be resolved + * @return corresponding user instance */ LevelUser getUser(Player player); + /** + * Returns the set of UUIDs known to the persistence backend. + * + * @return UUID snapshot of stored users + */ @NotNull Set getUuids(); } diff --git a/src/main/java/com/bitaspire/cyberlevels/user/LevelUser.java b/src/main/java/com/bitaspire/cyberlevels/user/LevelUser.java index ddb28cd..172a2b9 100644 --- a/src/main/java/com/bitaspire/cyberlevels/user/LevelUser.java +++ b/src/main/java/com/bitaspire/cyberlevels/user/LevelUser.java @@ -7,183 +7,247 @@ import java.util.UUID; /** - * Represents a user in the leveling system, providing methods to manage levels and experience points. + * Mutable view of one player's progression data. * - *

This interface includes functionalities for retrieving player information, managing levels, - * experience points, and checking permissions. + *

A {@code LevelUser} wraps identity, online state, current level, current EXP, progression + * helpers, and mutation operations such as adding EXP or changing levels. Implementations are + * expected to keep the in-memory state synchronized with the active level system and persistence + * backend. * - * @param the numeric type used for experience points and calculations + * @param numeric type used by the active level engine */ public interface LevelUser extends Comparable> { /** - * Gets the UUID of the user. - * @return the UUID + * Returns the unique identifier of this user. + * + * @return player UUID */ @NotNull UUID getUuid(); /** - * Gets the OfflinePlayer object associated with this user. - * @return the OfflinePlayer object + * Returns the Bukkit offline player handle associated with this user. + * + * @return offline player view */ OfflinePlayer getOffline(); /** - * Gets the Player object associated with this user. - * @return the Player object + * Returns the live Bukkit player handle. + * + *

Implementations may throw if the user is currently offline, so callers that are not sure + * about connection state should prefer {@link #isOnline()} or {@link #getOffline()} first. + * + * @return live player handle */ @NotNull Player getPlayer(); + /** + * Returns the most relevant player name for this user. + * + * @return player name as stored or resolved by the implementation + */ @NotNull String getName(); + /** + * Indicates whether the player is currently online. + * + * @return {@code true} when a live Bukkit player is available + */ boolean isOnline(); /** - * Gets the current level of the user. - * @return the current level + * Returns the user's current level. + * + * @return current level */ long getLevel(); /** - * Adds levels to the user's current level. - * @param amount the number of levels to add + * Adds one or more levels to the user. + * + * @param amount number of levels to add */ void addLevel(long amount); /** - * Sets the user's level to a specific value. - * @param amount the level to set - * @param sendMessage whether to send a message to the user about the level change + * Sets the user's level to an explicit value. + * + * @param amount target level + * @param sendMessage whether the user should receive the standard level-change feedback */ void setLevel(long amount, boolean sendMessage); /** - * Removes levels from the user's current level. - * @param amount the number of levels to remove + * Removes one or more levels from the user. + * + * @param amount number of levels to remove */ void removeLevel(long amount); /** - * Gets the current experience points of the user. - * @return the current experience points + * Returns the user's current EXP value. + * + * @return current EXP */ @NotNull N getExp(); /** - * Gets the experience points required for the user to reach the next level. - * @return the experience points required for the next level + * Returns the EXP required for the user to reach the next level. + * + * @return required EXP for the current level */ @NotNull N getRequiredExp(); /** - * Gets the remaining experience points needed for the user to reach the next level. - * @return the remaining experience points needed + * Returns how much EXP the user still needs before leveling up. + * + * @return remaining EXP to the next level */ @NotNull N getRemainingExp(); /** - * Gets the percentage of experience points the user has towards the next level. - * @return the percentage as a string + * Returns the formatted completion percentage toward the next level. + * + * @return percentage string ready for display */ @NotNull String getPercent(); /** - * Gets the progress bar representing the user's experience towards the next level. - * @return the progress bar as a string + * Returns the formatted progress bar for the user's current EXP state. + * + * @return progress bar string ready for display */ @NotNull String getProgressBar(); /** - * Adds experience points to the user. + * Adds EXP to the user. * - * @param amount the amount of experience points to add - * @param multiply whether to apply the user's multiplier to the added experience + * @param amount EXP amount to add + * @param multiply whether the player's multiplier should be applied first */ void addExp(N amount, boolean multiply); /** - * Adds experience points to the user. + * Adds EXP to the user from a primitive {@code double} value. + * + *

This overload exists for hot event paths where a primitive value is already available. + * + * @param amount EXP amount to add + * @param multiply whether the player's multiplier should be applied first + */ + default void addExp(double amount, boolean multiply) { + addExp(String.valueOf(amount), multiply); + } + + /** + * Adds EXP to the user from a string representation. * - * @param amount the amount of experience points to add, as a string - * @param multiply whether to apply the user's multiplier to the added experience + * @param amount EXP amount to add as text + * @param multiply whether the player's multiplier should be applied first */ void addExp(String amount, boolean multiply); /** - * Sets the user's experience points to a specific value. + * Sets the user's EXP to an explicit value. * - * @param amount the experience points to set - * @param checkLevel whether to check and update the user's level based on the new experience - * @param sendMessage whether to send a message to the user about the experience change - * @param checkLeaderboard whether to update the leaderboard with the new experience + * @param amount target EXP value + * @param checkLevel whether level-up or level-down logic should run afterward + * @param sendMessage whether the user should receive the standard EXP-change feedback + * @param checkLeaderboard whether the leaderboard should be refreshed or marked dirty */ void setExp(N amount, boolean checkLevel, boolean sendMessage, boolean checkLeaderboard); /** - * Sets the user's experience points to a specific value. + * Sets the user's EXP from a string representation. * - * @param amount the experience points to set, as a string - * @param checkLevel whether to check and update the user's level based on the new experience - * @param sendMessage whether to send a message to the user about the experience change - * @param checkLeaderboard whether to update the leaderboard with the new experience + * @param amount target EXP value as text + * @param checkLevel whether level-up or level-down logic should run afterward + * @param sendMessage whether the user should receive the standard EXP-change feedback + * @param checkLeaderboard whether the leaderboard should be refreshed or marked dirty */ void setExp(String amount, boolean checkLevel, boolean sendMessage, boolean checkLeaderboard); /** - * Sets the user's experience points to a specific value. + * Sets the user's EXP from a primitive {@code double}. + * + * @param amount target EXP value + * @param checkLevel whether level-up or level-down logic should run afterward + * @param sendMessage whether the user should receive the standard EXP-change feedback + * @param checkLeaderboard whether the leaderboard should be refreshed or marked dirty + */ + default void setExp(double amount, boolean checkLevel, boolean sendMessage, boolean checkLeaderboard) { + setExp(String.valueOf(amount), checkLevel, sendMessage, checkLeaderboard); + } + + /** + * Sets the user's EXP and keeps leaderboard updates enabled by default. * - * @param amount the experience points to set - * @param checkLevel whether to check and update the user's level based on the new experience - * @param sendMessage whether to send a message to the user about the experience change + * @param amount target EXP value + * @param checkLevel whether level-up or level-down logic should run afterward + * @param sendMessage whether the user should receive the standard EXP-change feedback */ default void setExp(N amount, boolean checkLevel, boolean sendMessage) { setExp(amount, checkLevel, sendMessage, true); } /** - * Sets the user's experience points to a specific value. + * Sets the user's EXP from a string value and keeps leaderboard updates enabled by default. * - * @param amount the experience points to set, as a string - * @param checkLevel whether to check and update the user's level based on the new experience - * @param sendMessage whether to send a message to the user about the experience change + * @param amount target EXP value as text + * @param checkLevel whether level-up or level-down logic should run afterward + * @param sendMessage whether the user should receive the standard EXP-change feedback */ default void setExp(String amount, boolean checkLevel, boolean sendMessage) { setExp(amount, checkLevel, sendMessage, true); } /** - * Removes experience points from the user. - * @param amount the amount of experience points to remove + * Removes EXP from the user. + * + * @param amount EXP amount to remove */ void removeExp(N amount); /** - * Removes experience points from the user. - * @param amount the amount of experience points to remove, as a string + * Removes EXP from the user from a primitive {@code double}. + * + *

This overload exists for hot event paths where a primitive value is already available. + * + * @param amount EXP amount to remove */ - void removeExp(String amount); + default void removeExp(double amount) { + removeExp(String.valueOf(amount)); + } /** - * Checks if the user has a specific permission. + * Removes EXP from the user from a string representation. * - * @param permission the permission to check - * @param checkOp whether to consider operator status as having all permissions + * @param amount EXP amount to remove as text + */ + void removeExp(String amount); + + /** + * Checks whether the user has a permission, optionally treating operator status as a wildcard. * - * @return true if the user has the permission, false otherwise + * @param permission permission node to check + * @param checkOp whether operator status should automatically pass the check + * @return {@code true} when the user satisfies the permission requirement */ boolean hasParentPerm(String permission, boolean checkOp); /** - * Gets the experience multiplier for the user. - * @return the experience multiplier + * Returns the EXP multiplier currently applicable to the user. + * + * @return effective multiplier */ double getMultiplier(); } diff --git a/src/main/java/com/bitaspire/cyberlevels/user/UserManager.java b/src/main/java/com/bitaspire/cyberlevels/user/UserManager.java index 76aec26..e50cf2c 100644 --- a/src/main/java/com/bitaspire/cyberlevels/user/UserManager.java +++ b/src/main/java/com/bitaspire/cyberlevels/user/UserManager.java @@ -10,98 +10,121 @@ import java.util.UUID; /** - * Represents a manager for handling user data and interactions within a leveling system. + * Coordinates the live user cache used by CyberLevels. * - *

This interface provides methods to handle user-related operations, including interaction - * with a database if available. + *

The user manager is responsible for loading player data, saving it back to the persistence + * layer, exposing lookup helpers, and running the periodic auto-save cycle. It is the main bridge + * between Bukkit players, {@link LevelUser} objects, and the configured {@link Database}. * - * @param the numeric type used for experience points and calculations + * @param numeric type used by the active level engine */ public interface UserManager { /** - * Gets a set of all users managed by this UserManager. - * @return a set of LevelUser objects + * Returns the currently loaded users as a set snapshot. + * + * @return loaded users */ @NotNull Set> getUsers(); + /** + * Returns the currently loaded users as a list snapshot. + * + * @return loaded users in list form + */ @NotNull List> getUsersList(); /** - * Retrieves a user by their UUID. + * Resolves a loaded user by UUID. * - * @param uuid the UUID of the player - * @return the LevelUser object associated with the UUID, or null if not found + * @param uuid UUID of the player to resolve + * @return matching user, or {@code null} when not loaded */ LevelUser getUser(UUID uuid); /** - * Retrieves a user by their Player object. + * Convenience overload that resolves a user from a live player. * - * @param player the Player object - * @return the LevelUser object associated with the Player, or null if not found + * @param player player whose user object should be resolved + * @return matching user, or {@code null} when not loaded */ default LevelUser getUser(Player player) { return getUser(player.getUniqueId()); } + /** + * Resolves a user by player name. + * + * @param name player name to search for + * @return matching user, or {@code null} when not found + */ LevelUser getUser(String name); /** - * Gets the database associated with this UserManager, if any. - * @return the Database object, or null if no database is used + * Returns the persistence backend currently used by the manager, if any. + * + * @return active database implementation, or {@code null} when persistence is disabled */ @Nullable Database getDatabase(); /** - * Loads the player data into the system. - * @param offline the UUID object to load + * Loads an offline player's data into the live cache. + * + * @param offline offline player whose data should be loaded */ void loadPlayer(OfflinePlayer offline); /** - * Loads the player data into the system. - * @param player the Player object to load + * Loads a live player's data into the cache. + * + * @param player player whose data should be loaded */ void loadPlayer(Player player); /** - * Saves the player data to the system. + * Saves a live player's data and optionally removes it from memory. * - * @param player the Player object to save - * @param clearData if true, clears the player's data from memory after saving + * @param player player whose data should be saved + * @param clearData whether the in-memory user should be removed after saving */ void savePlayer(Player player, boolean clearData); /** - * Saves the user data to the system. - * @param user the LevelUser object to save + * Saves an already resolved user to the persistence layer. + * + * @param user user whose data should be saved */ void saveUser(LevelUser user); + /** + * Removes a user from both the persistence layer and the live cache when applicable. + * + * @param uuid UUID of the player that should be removed + */ void removeUser(UUID uuid); /** - * Loads all currently online players into the system. + * Loads every player currently online on the server. */ void loadOnlinePlayers(); /** - * Saves all currently online players' data to the system. - * @param clearData if true, clears all players' data from memory after saving + * Saves every currently loaded online player. + * + * @param clearData whether in-memory entries should be cleared after saving */ void saveOnlinePlayers(boolean clearData); /** - * Starts the auto-save task to periodically save user data. + * Starts the repeating auto-save task, if enabled by configuration. */ void startAutoSave(); /** - * Cancels the auto-save task. + * Cancels the repeating auto-save task, if one is running. */ void cancelAutoSave(); } diff --git a/src/main/java/com/bitaspire/cyberlevels/utility/SpigotUpdateChecker.java b/src/main/java/com/bitaspire/cyberlevels/utility/SpigotUpdateChecker.java new file mode 100644 index 0000000..21615f3 --- /dev/null +++ b/src/main/java/com/bitaspire/cyberlevels/utility/SpigotUpdateChecker.java @@ -0,0 +1,233 @@ +package com.bitaspire.cyberlevels.utility; + +import com.bitaspire.cyberlevels.CyberLevels; +import com.bitaspire.cyberlevels.cache.Lang; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Utility responsible for checking the public Spigot listing against the running plugin version. + * + *

The checker performs network I/O asynchronously, compares the remote and local version + * numbers, logs the outcome to console, and stores a compact notice snapshot that can later be + * delivered to operators through configurable language messages. + */ +public final class SpigotUpdateChecker { + + private static final int RESOURCE_ID = 98826; + private static final String UPDATE_URL = + "https://api.spigotmc.org/legacy/update.php?resource=" + RESOURCE_ID; + private static final String RESOURCE_PAGE_URL = + "https://www.spigotmc.org/resources/" + RESOURCE_ID + "/"; + private static final String DISCORD_URL = "https://discord.gg/DC4Gqj3y5V"; + private static final int CONNECT_TIMEOUT_MS = 8_000; + private static final int READ_TIMEOUT_MS = 8_000; + + private SpigotUpdateChecker() {} + + /** + * Starts an asynchronous update check against the legacy Spigot resource API. + * + *

When a difference is detected, the result is posted back to the main thread so the plugin + * can safely update its cached notice state, log the result, and optionally notify online + * operators through chat. Matching versions and transient lookup failures are treated as + * non-fatal no-ops. + * + * @param main plugin instance used for scheduling, config access, and version metadata + */ + public static void checkAsync(CyberLevels main) { + main.scheduler().runTaskAsynchronously(() -> { + final String remote; + try { + remote = fetchLatestVersion(main); + } catch (Exception ignored) { + return; + } + + if (remote == null || remote.isEmpty()) return; + + final String local = main.getDescription().getVersion(); + final int cmp = compareVersions(remote, local); + if (cmp == 0) { + main + .scheduler() + .runTask(() -> + main.setSpigotOpUpdateNotice( + CyberLevels.SpigotOpUpdateNotice.none() + ) + ); + return; + } + + if (cmp > 0) { + main.scheduler().runTask(() -> { + if (!main.isEnabled()) return; + main.setSpigotOpUpdateNotice( + CyberLevels.SpigotOpUpdateNotice.newer(remote, local) + ); + final String console = + "A newer version is available on Spigot: " + + remote + + " (you are running " + + local + + "). Download: " + + RESOURCE_PAGE_URL; + main.getLogger().warning(console); + if (main.cache().config().spigotUpdateCheckNotifyOpsChat()) { + messageOpsLang(main); + } + }); + return; + } + + main.scheduler().runTask(() -> { + if (!main.isEnabled()) return; + main.setSpigotOpUpdateNotice( + CyberLevels.SpigotOpUpdateNotice.earlyAccess(local) + ); + final String console = + "You are running an early access build (" + + local + + "). If you encounter any issues, please report them on our Discord: " + + DISCORD_URL; + main.getLogger().info(console); + if (main.cache().config().spigotUpdateCheckNotifyOpsChat()) { + messageOpsLang(main); + } + }); + }); + } + + /** + * Delivers the cached update notice to a joining operator when chat notifications are enabled. + * + *

This method is designed for use from join listeners so operators who connect after the + * asynchronous check still receive the latest notice without requiring another network request. + * + * @param main plugin instance holding the cached notice and language config + * @param player player who just joined the server + */ + public static void deliverPendingOpChatOnJoin( + CyberLevels main, + Player player + ) { + if (!player.isOp()) return; + if (!main.cache().config().spigotUpdateCheckNotifyOpsChat()) return; + sendOpLangNotice(main, player, main.getSpigotOpUpdateNotice()); + } + + private static void messageOpsLang(CyberLevels main) { + final CyberLevels.SpigotOpUpdateNotice notice = + main.getSpigotOpUpdateNotice(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.isOp()) sendOpLangNotice(main, player, notice); + } + } + + private static void sendOpLangNotice( + CyberLevels main, + Player player, + CyberLevels.SpigotOpUpdateNotice notice + ) { + if (notice.getKind() == CyberLevels.SpigotOpUpdateNotice.KIND_NONE) return; + if (notice.getKind() == CyberLevels.SpigotOpUpdateNotice.KIND_NEWER) { + main.cache().lang().sendMessage( + player, + Lang::getSpigotUpdateNewerChat, + new String[] { "remoteVersion", "localVersion", "resourceUrl" }, + notice.getRemoteVersion(), + notice.getLocalVersion(), + RESOURCE_PAGE_URL + ); + return; + } + if ( + notice.getKind() == CyberLevels.SpigotOpUpdateNotice.KIND_EARLY + ) { + main.cache().lang().sendMessage( + player, + Lang::getSpigotUpdateEarlyAccessChat, + new String[] { "localVersion", "discordUrl" }, + notice.getLocalVersion(), + DISCORD_URL + ); + } + } + + private static String fetchLatestVersion(JavaPlugin plugin) + throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(UPDATE_URL) + .openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestMethod("GET"); + conn.setRequestProperty( + "User-Agent", + plugin.getName() + "/" + plugin.getDescription().getVersion() + ); + + final int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) return null; + + try ( + BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8) + ) + ) { + final String line = reader.readLine(); + return line != null ? line.trim() : null; + } finally { + conn.disconnect(); + } + } + + /** + * Compares two version strings using the numeric core of each dot-separated segment. + * + *

Prerelease suffixes are ignored by stripping everything after the first dash. Each segment + * then contributes only its leading digits, which keeps the comparison tolerant of mixed labels + * such as {@code 2.0.0-beta1}. + * + * @param a first version, typically the value returned by the Spigot API + * @param b second version, typically the running plugin version + * @return a positive value when {@code a} is newer, a negative value when {@code b} is newer, + * or {@code 0} when both numeric cores are equivalent + */ + private static int compareVersions(String a, String b) { + final String[] pa = normalizeVersionCore(a).split("\\."); + final String[] pb = normalizeVersionCore(b).split("\\."); + final int n = Math.max(pa.length, pb.length); + for (int i = 0; i < n; i++) { + final int na = parseNumericSegment(i < pa.length ? pa[i] : "0"); + final int nb = parseNumericSegment(i < pb.length ? pb[i] : "0"); + if (na != nb) return Integer.compare(na, nb); + } + return 0; + } + + private static String normalizeVersionCore(String version) { + if (version == null) return "0"; + final int dash = version.indexOf('-'); + return dash >= 0 ? version.substring(0, dash) : version; + } + + private static int parseNumericSegment(String segment) { + if (segment == null || segment.isEmpty()) return 0; + int end = 0; + while (end < segment.length() && Character.isDigit(segment.charAt(end))) { + end++; + } + if (end == 0) return 0; + try { + return Integer.parseInt(segment.substring(0, end)); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/bitaspire/libs/formula/BigDecimalExpressionBuilder.java b/src/main/java/com/bitaspire/libs/formula/BigDecimalExpressionBuilder.java deleted file mode 100644 index c9eb4d3..0000000 --- a/src/main/java/com/bitaspire/libs/formula/BigDecimalExpressionBuilder.java +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula; - -import ch.obermuhlner.math.big.BigDecimalMath; -import com.bitaspire.libs.formula.expression.ExpressionBuilder; -import com.bitaspire.libs.formula.expression.ExpressionConfig; -import com.bitaspire.libs.formula.expression.ExpressionDictionary; -import com.bitaspire.libs.formula.expression.ExpressionParameter; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; -import lombok.Getter; - -import java.math.BigDecimal; -import java.math.MathContext; -import java.math.RoundingMode; -import java.util.Collections; -import java.util.stream.Collectors; - -/** - * The BigDecimalExpressionBuilder class provides an implementation to parse expressions for parameters of type {@link BigDecimal}.
- * - * @author Pratanu Mandal - * @since 1.0 - * - */ -@Getter -public class BigDecimalExpressionBuilder extends ExpressionBuilder { - - /** Default math context */ - public static final MathContext DEFAULT_CONTEXT = new MathContext(20, RoundingMode.HALF_UP); - - private MathContext mathContext; - - /** - * No-Argument Constructor. - */ - public BigDecimalExpressionBuilder() { - this(DEFAULT_CONTEXT); - } - - /** - * Parameterized constructor. - * - * @param precision Precision - */ - public BigDecimalExpressionBuilder(int precision) { - this(precision, DEFAULT_CONTEXT.getRoundingMode()); - } - - /** - * Parameterized constructor. - * - * @param precision Precision - * @param roundingMode Rounding mode - */ - public BigDecimalExpressionBuilder(int precision, RoundingMode roundingMode) { - this(new MathContext(precision, roundingMode)); - } - - /** - * Parameterized constructor. - * - * @param mathContext Math context - */ - public BigDecimalExpressionBuilder(MathContext mathContext) { - super(new ExpressionConfig() { - @Override - protected BigDecimal stringToOperand(String operand) { - return new BigDecimal(operand); - } - - @Override - protected String operandToString(BigDecimal operand) { - return operand.toString(); - } - }); - - this.mathContext = mathContext; - this.initialize(); - } - - /** - * Initialize the operators, functions, and constants. - */ - protected void initialize() { - ExpressionDictionary expressionDictionary = this.getExpressionDictionary(); - - expressionDictionary.addOperator(new Operator<>("+", OperatorType.PREFIX, Integer.MAX_VALUE, (parameters) -> parameters.get(0).value())); - expressionDictionary.addOperator(new Operator<>("-", OperatorType.PREFIX, Integer.MAX_VALUE, (parameters) -> parameters.get(0).value().negate())); - - expressionDictionary.addOperator(new Operator<>("+", OperatorType.INFIX, 1, (parameters) -> parameters.get(0).value().add(parameters.get(1).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("-", OperatorType.INFIX, 1, (parameters) -> parameters.get(0).value().subtract(parameters.get(1).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("*", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value().multiply(parameters.get(1).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("/", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value().divide(parameters.get(1).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("%", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value().remainder(parameters.get(1).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("^", OperatorType.INFIX_RTL, 3, (parameters) -> BigDecimalMath.pow(parameters.get(0).value(), parameters.get(1).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("!", OperatorType.POSTFIX, 5, (parameters) -> BigDecimalUtils.factorial(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("abs", OperatorType.PREFIX, 4, (parameters) -> parameters.get(0).value().abs(mathContext))); - - expressionDictionary.addOperator(new Operator<>("sin", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.sin(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("cos", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.cos(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("tan", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.tan(parameters.get(0).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("asin", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.asin(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("acos", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.acos(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("atan", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.atan(parameters.get(0).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("sinh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.sinh(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("cosh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.cosh(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("tanh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.tanh(parameters.get(0).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("asinh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.asinh(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("acosh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.acosh(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("atanh", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.atanh(parameters.get(0).value(), mathContext))); - - expressionDictionary.addFunction(new Function<>("deg", 1, (parameters) -> BigDecimalMath.toDegrees(parameters.get(0).value(), mathContext))); - expressionDictionary.addFunction(new Function<>("rad", 1, (parameters) -> BigDecimalMath.toRadians(parameters.get(0).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("round", OperatorType.PREFIX, 4, (parameters) -> parameters.get(0).value().setScale(0, RoundingMode.HALF_UP))); - expressionDictionary.addOperator(new Operator<>("floor", OperatorType.PREFIX, 4, (parameters) -> parameters.get(0).value().setScale(0, RoundingMode.FLOOR))); - expressionDictionary.addOperator(new Operator<>("ceil", OperatorType.PREFIX, 4, (parameters) -> parameters.get(0).value().setScale(0, RoundingMode.CEILING))); - - expressionDictionary.addOperator(new Operator<>("ln", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.log(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("log10", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.log10(parameters.get(0).value(), mathContext))); - expressionDictionary.addFunction(new Function<>("log", 2, (parameters) -> BigDecimalUtils.log(parameters.get(1).value(), parameters.get(0).value(), mathContext))); - - expressionDictionary.addOperator(new Operator<>("sqrt", OperatorType.PREFIX, 4, (parameters) -> BigDecimalMath.sqrt(parameters.get(0).value(), mathContext))); - expressionDictionary.addOperator(new Operator<>("cbrt", OperatorType.PREFIX, 4, (parameters) -> BigDecimalUtils.cbrt(parameters.get(0).value(), mathContext))); - - expressionDictionary.addFunction(new Function<>("exp", 1, (parameters) -> BigDecimalMath.exp(parameters.get(0).value(), mathContext))); - - expressionDictionary.addFunction(new Function<>("max", (parameters) -> parameters.isEmpty() ? BigDecimal.ZERO : Collections.max(parameters.stream().map(ExpressionParameter::value).collect(Collectors.toList())))); - expressionDictionary.addFunction(new Function<>("min", (parameters) -> parameters.isEmpty() ? BigDecimal.ZERO : Collections.min(parameters.stream().map(ExpressionParameter::value).collect(Collectors.toList())))); - - expressionDictionary.addFunction(new Function<>("mean", (parameters) -> BigDecimalUtils.average(parameters.stream().map(ExpressionParameter::value).collect(Collectors.toList()), mathContext))); - expressionDictionary.addFunction(new Function<>("average", (parameters) -> BigDecimalUtils.average(parameters.stream().map(ExpressionParameter::value).collect(Collectors.toList()), mathContext))); - - expressionDictionary.addFunction(new Function<>("rand", 0, (parameters) -> new BigDecimal(Math.random()))); - - expressionDictionary.addConstant("pi", BigDecimalMath.pi(mathContext)); - expressionDictionary.addConstant("e", BigDecimalMath.e(mathContext)); - } - - /** - * Set the math context. - * - * @param mathContext Math context - */ - public void setMathContext(MathContext mathContext) { - this.reset(); - this.mathContext = mathContext; - this.initialize(); - } -} diff --git a/src/main/java/com/bitaspire/libs/formula/BigDecimalUtils.java b/src/main/java/com/bitaspire/libs/formula/BigDecimalUtils.java deleted file mode 100644 index ae5da5e..0000000 --- a/src/main/java/com/bitaspire/libs/formula/BigDecimalUtils.java +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula; - -import ch.obermuhlner.math.big.BigDecimalMath; -import com.bitaspire.libs.formula.exception.Expr4jException; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.math.MathContext; -import java.util.List; -import java.util.Objects; - -/** - * The BigDecimalUtils class provides extra math functionality not available for {@link BigDecimal} class. - * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public class BigDecimalUtils { - - /** - * Utility classes should not have public constructors. - */ - private BigDecimalUtils() {} - - /** - * Compare two {@code BigDecimal} instances with a specified precision. - * - * @param x First {@code BigDecimal} instance - * @param y Second {@code BigDecimal} instance - * @param precision Precision - * @return a negative integer, zero, or a positive integer as the first instance is less than, equal to, or greater than the second instance - */ - public static int compare(BigDecimal x, BigDecimal y, int precision) { - BigDecimal epsilon = BigDecimal.ONE.movePointLeft(precision); - BigDecimal absDelta = x.subtract(y).abs(); - int result = absDelta.compareTo(epsilon); - return result <= 0 ? 0 : x.compareTo(y); - } - - /** - * Check if two {@code BigDecimal} instances are equal with a specified precision. - * - * @param x First {@code BigDecimal} instance - * @param y Second {@code BigDecimal} instance - * @param precision Precision - * @return True if both instances are equal, false otherwise - */ - public static boolean equals(BigDecimal x, BigDecimal y, int precision) { - return compare(x, y, precision) == 0; - } - - /** - * Check if a {@code BigDecimal} instance is an integer. - * - * @param x The {@code BigDecimal} instance - * @return True if the instance is an integer, false otherwise - */ - public static boolean isInteger(BigDecimal x) { - return x.stripTrailingZeros().scale() <= 0; - } - - /** - * Calculate the log of x to the base y. - * - * @param x The operand - * @param y The base or radix - * @param mathContext Math context - * @return Log of x to the base y - */ - public static BigDecimal log(BigDecimal x, BigDecimal y, MathContext mathContext) { - return BigDecimalMath.log(x, mathContext).divide(BigDecimalMath.log(y, mathContext), mathContext); - } - - /** - * Calculate the cube root of x. - * - * @param x The operand - * @param mathContext Math context - * @return Cube root of x - */ - public static BigDecimal cbrt(BigDecimal x, MathContext mathContext) { - return BigDecimalMath.pow(x, - BigDecimal.ONE.divide(new BigDecimal(3), mathContext), - mathContext); - } - - /** - * Calculate average (mean) of a list of operands. - * - * @param list List of operands - * @param mathContext Math context - * @return Average (mean) of the list of operands - */ - public static BigDecimal average(List list, MathContext mathContext) { - BigDecimal sum = list.stream() - .map(Objects::requireNonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add); - return sum.divide(new BigDecimal(list.size()), mathContext); - } - - /** - * Calculate the factorial of an integer. - * - * @param n The integer - * @return Factorial of n - */ - private static BigDecimal factorial(BigInteger n) { - BigInteger factorial = BigInteger.ONE; - while (n.compareTo(BigInteger.ONE) > 0) { - factorial = factorial.multiply(n); - n = n.subtract(BigInteger.ONE); - } - return new BigDecimal(factorial); - } - - /** - * Calculate the factorial if it is an integer. - * - * @param x The operand - * @return Factorial of x - */ - public static BigDecimal factorial(BigDecimal x) { - if (x == null || x.compareTo(BigDecimal.ZERO) < 0 || !isInteger(x)) { - throw new Expr4jException("Cannot calculate factorial of " + x); - } - return factorial(x.toBigInteger()); - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/DoubleExpressionBuilder.java b/src/main/java/com/bitaspire/libs/formula/DoubleExpressionBuilder.java deleted file mode 100644 index 3217852..0000000 --- a/src/main/java/com/bitaspire/libs/formula/DoubleExpressionBuilder.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.bitaspire.libs.formula; - -import com.bitaspire.libs.formula.expression.ExpressionBuilder; -import com.bitaspire.libs.formula.expression.ExpressionConfig; -import com.bitaspire.libs.formula.expression.ExpressionDictionary; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; - -import java.util.Collections; -import java.util.stream.Collectors; - -public class DoubleExpressionBuilder extends ExpressionBuilder { - - public DoubleExpressionBuilder() { - super(new ExpressionConfig() { - @Override - protected Double stringToOperand(String operand) { - return Double.parseDouble(operand); - } - - protected String operandToString(Double operand) { - return operand == operand.intValue() ? String.valueOf(operand.intValue()) : operand.toString(); - } - }); - - this.initialize(); - } - - protected void initialize() { - ExpressionDictionary expressionDictionary = this.getExpressionDictionary(); - - expressionDictionary.addOperator(new Operator<>("+", OperatorType.PREFIX, Integer.MAX_VALUE, (parameters) -> parameters.get(0).value())); - expressionDictionary.addOperator(new Operator<>("-", OperatorType.PREFIX, Integer.MAX_VALUE, (parameters) -> -parameters.get(0).value())); - - expressionDictionary.addOperator(new Operator<>("+", OperatorType.INFIX, 1, (parameters) -> parameters.get(0).value() + parameters.get(1).value())); - expressionDictionary.addOperator(new Operator<>("-", OperatorType.INFIX, 1, (parameters) -> parameters.get(0).value() - parameters.get(1).value())); - - expressionDictionary.addOperator(new Operator<>("*", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value() * parameters.get(1).value())); - expressionDictionary.addOperator(new Operator<>("/", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value() / parameters.get(1).value())); - expressionDictionary.addOperator(new Operator<>("%", OperatorType.INFIX, 2, (parameters) -> parameters.get(0).value() % parameters.get(1).value())); - - expressionDictionary.addOperator(new Operator<>("^", OperatorType.INFIX_RTL, 3, (parameters) -> Math.pow(parameters.get(0).value(), parameters.get(1).value()))); - - expressionDictionary.addOperator(new Operator<>("!", OperatorType.POSTFIX, 5, (parameters) -> DoubleUtils.factorial(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("abs", OperatorType.PREFIX, 4, (parameters) -> Math.abs(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("sin", OperatorType.PREFIX, 4, (parameters) -> Math.sin(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("cos", OperatorType.PREFIX, 4, (parameters) -> Math.cos(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("tan", OperatorType.PREFIX, 4, (parameters) -> Math.tan(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("asin", OperatorType.PREFIX, 4, (parameters) -> Math.asin(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("acos", OperatorType.PREFIX, 4, (parameters) -> Math.acos(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("atan", OperatorType.PREFIX, 4, (parameters) -> Math.atan(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("sinh", OperatorType.PREFIX, 4, (parameters) -> Math.sinh(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("cosh", OperatorType.PREFIX, 4, (parameters) -> Math.cosh(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("tanh", OperatorType.PREFIX, 4, (parameters) -> Math.tanh(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("asinh", OperatorType.PREFIX, 4, (parameters) -> DoubleUtils.asinh(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("acosh", OperatorType.PREFIX, 4, (parameters) -> DoubleUtils.acosh(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("atanh", OperatorType.PREFIX, 4, (parameters) -> DoubleUtils.atanh(parameters.get(0).value()))); - - expressionDictionary.addFunction(new Function<>("deg", 1, (parameters) -> Math.toDegrees(parameters.get(0).value()))); - expressionDictionary.addFunction(new Function<>("rad", 1, (parameters) -> Math.toRadians(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("round", OperatorType.PREFIX, 4, (parameters) -> (double) Math.round(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("floor", OperatorType.PREFIX, 4, (parameters) -> Math.floor(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("ceil", OperatorType.PREFIX, 4, (parameters) -> Math.ceil(parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("ln", OperatorType.PREFIX, 4, (parameters) -> Math.log(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("log10", OperatorType.PREFIX, 4, (parameters) -> Math.log10(parameters.get(0).value()))); - expressionDictionary.addFunction(new Function<>("log", 2, (parameters) -> DoubleUtils.log(parameters.get(1).value(), parameters.get(0).value()))); - - expressionDictionary.addOperator(new Operator<>("sqrt", OperatorType.PREFIX, 4, (parameters) -> Math.sqrt(parameters.get(0).value()))); - expressionDictionary.addOperator(new Operator<>("cbrt", OperatorType.PREFIX, 4, (parameters) -> Math.cbrt(parameters.get(0).value()))); - - expressionDictionary.addFunction(new Function<>("exp", 1, (parameters) -> Math.exp(parameters.get(0).value()))); - - expressionDictionary.addFunction(new Function<>("max", (parameters) -> parameters.isEmpty() ? 0.0 : Collections.max(parameters.stream().map(e -> e.value()).collect(Collectors.toList())))); - expressionDictionary.addFunction(new Function<>("min", (parameters) -> parameters.isEmpty() ? 0.0 : Collections.min(parameters.stream().map(e -> e.value()).collect(Collectors.toList())))); - - expressionDictionary.addFunction(new Function<>("mean", (parameters) -> DoubleUtils.average(parameters.stream().map(e -> e.value()).collect(Collectors.toList())))); - expressionDictionary.addFunction(new Function<>("average", (parameters) -> DoubleUtils.average(parameters.stream().map(e -> e.value()).collect(Collectors.toList())))); - - expressionDictionary.addFunction(new Function<>("rand", 0, (parameters) -> Math.random())); - - expressionDictionary.addConstant("pi", Math.PI); - expressionDictionary.addConstant("e", Math.E); - } -} diff --git a/src/main/java/com/bitaspire/libs/formula/DoubleUtils.java b/src/main/java/com/bitaspire/libs/formula/DoubleUtils.java deleted file mode 100644 index f219b88..0000000 --- a/src/main/java/com/bitaspire/libs/formula/DoubleUtils.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.bitaspire.libs.formula; - -import com.bitaspire.libs.formula.exception.Expr4jException; - -import java.util.List; - -public class DoubleUtils { - - private DoubleUtils() {} - - public static double asinh(double x) { - return Math.log(x + Math.sqrt(x * x + 1)); - } - - public static double acosh(double x) { - return Math.log(x + Math.sqrt(x * x - 1)); - } - - public static double atanh(double x) { - return 0.5 * Math.log((1 + x) / (1 - x)); - } - - public static double log(double x, double y) { - return Math.log(x) / Math.log(y); - } - - public static Double average(List list) { - return list.stream().mapToDouble(d -> d).average().orElse(0.0); - } - - private static double factorial(int n) { - double factorial = 1.0; - for (int i = 2; i <= n; i++) { - factorial *= i; - } - return factorial; - } - - public static double factorial(double x) { - if (x < 0 || x != (int) x) { - throw new Expr4jException("Cannot calculate factorial of " + x); - } - return factorial((int) x); - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/exception/Expr4jException.java b/src/main/java/com/bitaspire/libs/formula/exception/Expr4jException.java deleted file mode 100644 index 3b1fd91..0000000 --- a/src/main/java/com/bitaspire/libs/formula/exception/Expr4jException.java +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.exception; - -/** - * The Expr4jException class represents exceptions that can be thrown during the execution of Expr4j library. - * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public class Expr4jException extends RuntimeException { - - /** - * Serial Version UID for object serialization. - */ - private static final long serialVersionUID = 6989809082307883828L; - - /** - * Constructs a new Expr4j exception with null as its detail message. - */ - public Expr4jException() { - super(); - } - - /** - * Constructs a new Expr4j exception with the specified detail message, cause, suppression enabled or disabled, and writable stack trace enabled or disabled. - * - * @param message the detail message - * @param cause the cause of the exception - * @param enableSuppression whether suppression is enabled or disabled - * @param writableStackTrace whether the stack trace should be writable - */ - public Expr4jException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } - - /** - * Constructs a new Expr4j exception with the specified detail message and cause. - * - * @param message the detail message - * @param cause the cause of the exception - */ - public Expr4jException(String message, Throwable cause) { - super(message, cause); - } - - /** - * Constructs a new Expr4j exception with the specified detail message. - * - * @param message the detail message - */ - public Expr4jException(String message) { - super(message); - } - - /** - * Constructs a new Expr4j exception with the specified cause and a detail message of (cause==null ? null : cause.toString()) (which typically contains the class and detail message of cause). - * - * @param cause the cause of the exception - */ - public Expr4jException(Throwable cause) { - super(cause); - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/Expression.java b/src/main/java/com/bitaspire/libs/formula/expression/Expression.java deleted file mode 100644 index f845db9..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/Expression.java +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operand; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; -import com.bitaspire.libs.formula.token.Variable; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * The Expression<T> class represents a parsed expression that can be evaluated. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class Expression { - - /** - * Root node of the expression tree. - */ - public ExpressionNode root; - - /** - * Expression dictionary. - */ - private final ExpressionDictionary expressionDictionary; - - /** - * Expression configuration. - */ - private final ExpressionConfig expressionConfig; - - /** - * Parameterized constructor. - * - * @param expressionDictionary The expression dictionary - * @param expressionConfig The expression configuration - */ - public Expression(ExpressionDictionary expressionDictionary, - ExpressionConfig expressionConfig) { - this.expressionDictionary = expressionDictionary; - this.expressionConfig = expressionConfig; - } - - /** - * Recursively evaluate the expression tree and return the result. - * - * @param node Current node of the expression tree - * @param variables Map of variables - * @return Result of expression evaluation - */ - @SuppressWarnings("unchecked") - protected Operand evaluate(ExpressionNode node, Map variables) { - // encountered variable - if (node.token instanceof Variable) { - Variable variable = (Variable) node.token; - - if (!variables.containsKey(variable.label)) { - throw new Expr4jException("Variable not found: " + variable.label); - } - - return new Operand(variables.get(variable.label)); - } - - // encountered function - else if (node.token instanceof Function) { - Function function = (Function) node.token; - - int operandCount = function.parameters; - if (node.children.size() != operandCount) { - throw new Expr4jException("Invalid expression"); - } - - List> parameters = node.children.stream() - .map(e -> new ExpressionParameter(this, e, variables)) - .collect(Collectors.toList()); - return new Operand(function.evaluate(parameters)); - } - - // encountered operator - else if (node.token instanceof Operator) { - Operator operator = (Operator) node.token; - - int operandCount = (operator.type == OperatorType.INFIX || operator.type == OperatorType.INFIX_RTL) ? 2 : 1; - if (node.children.size() != operandCount) { - throw new Expr4jException("Invalid expression"); - } - - List> parameters = node.children.stream() - .map(e -> new ExpressionParameter(this, e, variables)) - .collect(Collectors.toList()); - return new Operand(operator.evaluate(parameters)); - } - - // encountered operand - else { - return (Operand) node.token; - } - } - - /** - * Evaluate the expression against a set of variables.
- * Variables passed to this method override an predefined constants with the same label. - * - * @param variables Map of variables - * @return Evaluated result - */ - public T evaluate(Map variables) { - if (root == null) { - throw new Expr4jException("Invalid expression"); - } - - Map constantsAndVariables = new HashMap<>(expressionDictionary.constants); - if (variables != null) constantsAndVariables.putAll(variables); - - return evaluate(root, constantsAndVariables).value; - } - - /** - * Evaluate the expression. - * - * @return Evaluated result - */ - public T evaluate() { - return evaluate(new HashMap()); - } - - /** - * Form string representation of expression. - * - * @param node Current node of the expression tree - * @return Result of expression evaluation - */ - @SuppressWarnings("unchecked") - protected String toString(ExpressionNode node) { - // encountered variable - if (node.token instanceof Variable) { - Variable variable = (Variable) node.token; - return variable.label; - } - - // encountered function - else if (node.token instanceof Function) { - Function function = (Function) node.token; - - int operandCount = function.parameters; - if (node.children.size() != operandCount) { - throw new Expr4jException("Invalid expression"); - } - - String operands = node.children.stream().map(this::toString).collect(Collectors.joining(", ")); - - return function.label + "(" + operands + ")"; - } - - // encountered operator - else if (node.token instanceof Operator) { - Operator operator = (Operator) node.token; - - int operandCount = (operator.type == OperatorType.INFIX || operator.type == OperatorType.INFIX_RTL) ? 2 : 1; - if (node.children.size() != operandCount) { - throw new Expr4jException("Invalid expression"); - } - - String label; - if (operandCount == 2) label = " " + operator.label + " "; - else label = operator.label; - - if (operandCount == 2) { - StringBuilder sb = new StringBuilder(); - - ExpressionNode left = node.children.get(0); - ExpressionNode right = node.children.get(1); - - if (left.token instanceof Operator) { - Operator leftOperator = (Operator) left.token; - if (!leftOperator.label.equals("*") && - (leftOperator.type == OperatorType.INFIX || leftOperator.type == OperatorType.INFIX_RTL) && - operator.compareTo(leftOperator) < 0) { - sb.append("("); - sb.append(this.toString(left)); - sb.append(")"); - } else { - sb.append(this.toString(left)); - } - } else { - sb.append(this.toString(left)); - } - - sb.append(label); - - if (right.token instanceof Operator) { - Operator rightOperator = (Operator) right.token; - if (!rightOperator.label.equals("*") && - (rightOperator.type == OperatorType.INFIX || rightOperator.type == OperatorType.INFIX_RTL) && - operator.compareTo(rightOperator) < 0) { - sb.append("("); - sb.append(this.toString(right)); - sb.append(")"); - } else { - sb.append(this.toString(right)); - } - } else { - sb.append(this.toString(right)); - } - - return sb.toString(); - } else { - ExpressionNode child = node.children.get(0); - if (operator.label.equals("+") || operator.label.equals("-")) { - if (child.token instanceof Operator) { - Operator childOperator = (Operator) child.token; - if (childOperator.type == OperatorType.PREFIX) { - return label + this.toString(child); - } else { - return label + "(" + this.toString(child) + ")"; - } - } else { - return label + this.toString(child); - } - } else if (child.token instanceof Operator || child.token instanceof Function) { - if (operator.type == OperatorType.PREFIX) { - return label + "(" + this.toString(child) + ")"; - } else { - return "(" + this.toString(child) + ") " + label; - } - } else { - if (operator.type == OperatorType.PREFIX) { - return label + " " + this.toString(child); - } else { - return this.toString(child) + " " + label; - } - } - } - } - - // encountered operand - else { - Operand operand = (Operand) node.token; - return expressionConfig.operandToString(operand.value); - } - } - - /** - * Get string representation of expression. - */ - @Override - public String toString() { - return this.toString(root); - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionBuilder.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionBuilder.java deleted file mode 100644 index 2500c11..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionBuilder.java +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; -import com.bitaspire.libs.formula.token.Token; -import lombok.Getter; - -import java.util.List; -import java.util.Stack; - -/** - * The ExpressionBuilder<T> class provides a partial implementation to build expressions independent of the type of operand.
- * An expression is created from the postfix (or RPN) expression. The expression can then be evaluated.

- * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class ExpressionBuilder { - - /** - * Instance of expression. - */ - private Expression expression; - - /** - * Expression dictionary. - * -- GETTER -- - * Get the expression dictionary. - * - * @return The expression dictionary - - */ - @Getter - private ExpressionDictionary expressionDictionary; - - /** - * Expression configuration. - * -- GETTER -- - * Get the expression configuration. - * - * @return The expression configuration - - */ - @Getter - private final ExpressionConfig expressionConfig; - - /** - * Parameterized constructor - * - * @param expressionConfig The expression configuration - */ - public ExpressionBuilder(ExpressionConfig expressionConfig) { - this.expressionConfig = expressionConfig; - this.reset(); - } - - /** - * Reset the parser. - */ - public void reset() { - expressionDictionary = new ExpressionDictionary<>(); - } - - /** - * Method to form the expression tree recursively. - * - * @param node Current node of the expression tree - * @param token Token to be inserted - * @return true if token could be inserted, otherwise false - */ - @SuppressWarnings("unchecked") - private boolean formTree(ExpressionNode node, Token token) { - if (node.token instanceof Function) { - Function function = (Function) node.token; - - int operandCount = function.parameters; - - if (!node.children.isEmpty() && formTree(node.children.get(0), token)) { - return true; - } - else if (node.children.size() < operandCount) { - node.children.add(0, new ExpressionNode(token)); - return true; - } - } - else if (node.token instanceof Operator) { - Operator operator = (Operator) node.token; - - int operandCount = (operator.type == OperatorType.INFIX || operator.type == OperatorType.INFIX_RTL) ? 2 : 1; - - if (!node.children.isEmpty() && formTree(node.children.get(0), token)) { - return true; - } - else if (node.children.size() < operandCount) { - node.children.add(0, new ExpressionNode(token)); - return true; - } - } - - return false; - } - - /** - * Method to form the expression tree. - */ - private void formTree(Stack postfix) { - while (!postfix.isEmpty()) { - Token token = postfix.pop(); - - if (expression.root == null) { - expression.root = new ExpressionNode(token); - } - else { - boolean flag = formTree(expression.root, token); - - if (!flag) { - throw new Expr4jException("Invalid expression"); - } - } - } - } - - /** - * Method to parse an expression.
- * This method acts as the single point of access for expression parsing. - * - * @param expr Expression string - * @return The parsed expression - */ - public Expression build(String expr) { - try { - // initialize expression - this.expression = new Expression(expressionDictionary, expressionConfig); - - // tokenize the expression - ExpressionTokenizer tokenizer = new ExpressionTokenizer(expressionDictionary, expressionConfig); - List tokenList = tokenizer.tokenize(expr); - - // form the postfix expression - ExpressionParser parser = new ExpressionParser(); - Stack postfix = parser.parse(tokenList); - - // form the tree - this.formTree(postfix); - - return this.expression; - } - finally { - // clean up - expression evaluation can be a memory intensive process - this.expression = null; - } - } -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionConfig.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionConfig.java deleted file mode 100644 index 3f9de58..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionConfig.java +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import java.util.ArrayList; -import java.util.List; - -/** - * The ExpressionConfig<T> class defines configurations for the expression. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public abstract class ExpressionConfig { - - /** - * No-Argument Constructor. - */ - public ExpressionConfig() { - } - - /** - * Method to define procedure to obtain operand from string representation. - * - * @param operand String representation of operand - * @return Operand - */ - protected abstract T stringToOperand(String operand); - - /** - * Method to define procedure to obtain string representation of operand. - * - * @param operand Operand - * @return String representation of operand - */ - protected abstract String operandToString(T operand); - - /** - * Method to define the patterns to identify operands.
- * Override this method if the patterns to identify operands need to be customized. - * - * @return List of patterns to identify operands - */ - protected List getOperandPattern() { - List list = new ArrayList<>(); - list.add("(-?\\d+)(\\.\\d+)?(e-|e\\+|e|\\d+)\\d+"); - list.add("\\d*\\.?\\d+"); - return list; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionDictionary.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionDictionary.java deleted file mode 100644 index 378006c..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionDictionary.java +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; - -/** - * The ExpressionDictionary<T> class stores all operators, functions and constants of a specified type. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class ExpressionDictionary { - - /** Map of prefix operators */ - protected final Map> prefixMap; - - /** Map of postfix operators */ - protected final Map> postfixMap; - - /** Map of infix operators */ - protected final Map> infixMap; - - /** Map of functions */ - protected final Map> functionMap; - - /** Map of constants */ - protected Map constants; - - /** - * No-Argument Constructor. - */ - public ExpressionDictionary() { - this.prefixMap = new TreeMap<>(); - this.postfixMap = new TreeMap<>(); - this.infixMap = new TreeMap<>(); - this.functionMap = new TreeMap<>(); - this.constants = new TreeMap<>(); - } - - /** - * Add an operator. - * - * @param operator The operator - */ - public void addOperator(Operator operator) { - if (operator.type == OperatorType.PREFIX) { - prefixMap.put(operator.label, operator); - } - else if (operator.type == OperatorType.POSTFIX) { - postfixMap.put(operator.label, operator); - } - else { - infixMap.put(operator.label, operator); - } - } - - /** - * Remove all operators for a specified label irrespective of their type. - * - * @param label The label of the operators - */ - public void removeOperator(String label) { - removeOperator(label, null); - } - - /** - * Remove an operator for a specified label and type. - * If type is null, all operators for the specified label are removed irrespective of their type. - * - * @param label The label of the operator(s) - * @param type The type of the operator - */ - public void removeOperator(String label, OperatorType type) { - if (type == null) { - prefixMap.remove(label); - postfixMap.remove(label); - infixMap.remove(label); - } - else if (type == OperatorType.PREFIX) { - prefixMap.remove(label); - } - else if (type == OperatorType.POSTFIX) { - postfixMap.remove(label); - } - else { - infixMap.remove(label); - } - } - - /** - * Get set of all operators available. - * - * @return Set of all operators - */ - public Set> getOperators() { - Set> operatorSet = new HashSet<>(); - operatorSet.addAll(prefixMap.values()); - operatorSet.addAll(postfixMap.values()); - operatorSet.addAll(infixMap.values()); - return Collections.unmodifiableSet(operatorSet); - } - - /** - * Check if a prefix operator exists with specified label. - * - * @param label The label of the operator - * @return True if found, false otherwise - */ - public boolean hasPrefixOperator(String label) { - return prefixMap.containsKey(label); - } - - /** - * Get the prefix operator with specified label. - * - * @param label The label of the operator - * @return The operator if found, null otherwise - */ - public Operator getPrefixOperator(String label) { - return prefixMap.get(label); - } - - /** - * Check if a postfix operator exists with specified label. - * - * @param label The label of the operator - * @return True if found, false otherwise - */ - public boolean hasPostfixOperator(String label) { - return postfixMap.containsKey(label); - } - - /** - * Get the postfix operator with specified label. - * - * @param label The label of the operator - * @return The operator if found, null otherwise - */ - public Operator getPostfixOperator(String label) { - return postfixMap.get(label); - } - - /** - * Check if an infix operator exists with specified label. - * - * @param label The label of the operator - * @return True if found, false otherwise - */ - public boolean hasInfixOperator(String label) { - return infixMap.containsKey(label); - } - - /** - * Get the infix operator with specified label. - * - * @param label The label of the operator - * @return The operator if found, null otherwise - */ - public Operator getInfixOperator(String label) { - return infixMap.get(label); - } - - /** - * Add a function. - * - * @param function The function - */ - public void addFunction(Function function) { - functionMap.put(function.label, function); - } - - /** - * Remove a function for a specified label. - * - * @param label The label of the function - */ - public void removeFunction(String label) { - functionMap.remove(label); - } - - /** - * Get set of all functions available. - * - * @return Set of all functions - */ - public Set> getFunctions() { - Set> functionSet = new HashSet<>(); - functionSet.addAll(functionMap.values()); - return Collections.unmodifiableSet(functionSet); - } - - /** - * Check if a function exists with specified label. - * - * @param label The label of the function - * @return True if found, false otherwise - */ - public boolean hasFunction(String label) { - return functionMap.containsKey(label); - } - - /** - * Get the function with specified label. - * - * @param label The label of the function - * @return The function if found, null otherwise - */ - public Function getFunction(String label) { - return functionMap.get(label); - } - - /** - * Add a constant to the parser. - * - * @param label Label of the constant - * @param value Value of the constant - */ - public void addConstant(String label, T value) { - constants.put(label, value); - } - - /** - * Remove constant from the parser for the specified label if present. - * - * @param label Label of the constant - * @return Constant for the specified label if present, else null - */ - public T removeConstant(String label) { - return constants.remove(label); - } - - /** - * Get constant present in the parser for the specified label. - * - * @param label Label of the constant - * @return Constant for the specified label if present, else null - */ - public T getConstant(String label) { - if (!constants.containsKey(label)) { - throw new Expr4jException("Constant not found: " + label); - } - return constants.get(label); - } - - /** - * Get list of labels of all executables (operators and functions). - * - * @return The list of labels - */ - Set getExecutables() { - Set executables = new TreeSet<>(); - executables.addAll(prefixMap.keySet()); - executables.addAll(postfixMap.keySet()); - executables.addAll(infixMap.keySet()); - executables.addAll(functionMap.keySet()); - return executables; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionNode.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionNode.java deleted file mode 100644 index 8857ddd..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionNode.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.Token; - -import java.util.ArrayList; -import java.util.List; - -/** - * The ExpressionNode class represents a node of the expression tree.

- * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public class ExpressionNode { - - /** - * Children of this node. - */ - public final List children; - - /** - * Token contained in this node.
- * A token can be an operand, operator, function, variable, or constant. - */ - public final Token token; - - /** - * Parameterized constructor. - * - * @param token The token in this node - */ - public ExpressionNode(Token token) { - this.token = token; - if (token instanceof Function || token instanceof Operator) { - this.children = new ArrayList<>(); - } - else { - this.children = null; - } - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParameter.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParameter.java deleted file mode 100644 index 191aaf3..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParameter.java +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import java.util.Map; - -/** - * The ExpressionParameter<T> class represents a parameter of an operation.

- * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public class ExpressionParameter { - - /** - * Expression to which this parameter belongs. - */ - private Expression expression; - - /** - * Node of the expression related to this parameter. - */ - private ExpressionNode node; - - /** - * Map of variables. - */ - private Map variables; - - /** - * Result of evaluating this parameter. - */ - private T result; - - /** - * Parameterized constructor. - * - * @param expression The expression - * @param node The node - * @param variables Map of variables - */ - public ExpressionParameter(Expression expression, ExpressionNode node, Map variables) { - this.expression = expression; - this.node = node; - this.variables = variables; - } - - /** - * Get the evaluated result of this parameter. - * - * @return The evaluated result - */ - public T value() { - if (this.result == null) { - this.result = this.expression.evaluate(this.node, this.variables).value; - } - return this.result; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParser.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParser.java deleted file mode 100644 index 1a5d9a0..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionParser.java +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operand; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; -import com.bitaspire.libs.formula.token.Separator; -import com.bitaspire.libs.formula.token.Token; -import com.bitaspire.libs.formula.token.Variable; - -import java.util.List; -import java.util.Stack; - -/** - * The ExpressionParser<T> class parses expressions independent of the type of operand.
- * This class parses the tokenized expression to generate a postfix (RPN) expression.

- * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class ExpressionParser { - - /** - * Stack to hold the postfix (RPN) expression. - */ - private Stack postfix; - - /** - * Stack to hold the operators. - */ - private Stack operatorStack; - - /** - * Stack to hold the count of function parameters. - */ - private Stack functionStack; - - /** - * No-Argument Constructor. - */ - public ExpressionParser() { - } - - /** - * Method to create the postfix (RPN) expression from the infix expression. - * - * @param tokenList The token list - * @return The postfix expression - */ - public Stack parse(List tokenList) { - // initialize members - postfix = new Stack<>(); - operatorStack = new Stack<>(); - functionStack = new Stack<>(); - - boolean probableZeroFunction = false; - - Token lastToken = null; - - // iterate over tokens - for (int i = 0; i < tokenList.size(); i++) { - Token token = tokenList.get(i); - - if (token instanceof Separator) { - Separator separator = (Separator) token; - - // open bracket - if (separator == Separator.OPEN_BRACKET) { - operatorStack.push(separator); - } - - // close bracket - else if (separator == Separator.CLOSE_BRACKET) { - throwIfNotPostfix(lastToken); - throwIfOpenBracketOrComma(lastToken); - - if (probableZeroFunction) { - if (functionStack.isEmpty()) { - throw new Expr4jException("Invalid expression"); - } - functionStack.pop(); - functionStack.push(0); - } - - evaluateParenthesis(); - } - - // comma - else if (separator == Separator.COMMA) { - throwIfFunction(lastToken); - throwIfNotPostfix(lastToken); - throwIfOpenBracketOrComma(lastToken); - - while (!operatorStack.isEmpty() && !(operatorStack.peek() instanceof Function)) { - postfix.push(operatorStack.pop()); - } - - if (functionStack.isEmpty()) { - throw new Expr4jException("Invalid expression"); - } - functionStack.push(functionStack.pop() + 1); - } - - probableZeroFunction = false; - } - - // functions - else if (token instanceof Function) { - Function function = (Function) token; - - Token nextToken = i != tokenList.size() - 1 ? tokenList.get(i + 1) : null; - throwIfNoOpenBracket(nextToken, function); - - i++; - - operatorStack.push(function); - - if (function.parameters == 0) functionStack.push(0); - else functionStack.push(1); - - if (function.parameters == Function.VARIABLE_PARAMETERS) probableZeroFunction = true; - } - - // operators - else if (token instanceof Operator) { - Operator operator = (Operator) token; - - if (operator.type == OperatorType.INFIX || operator.type == OperatorType.INFIX_RTL) { - throwIfNull(lastToken); - throwIfFunction(lastToken); - throwIfNotPostfix(lastToken); - throwIfOpenBracketOrComma(lastToken); - } - else if (operator.type == OperatorType.POSTFIX) { - throwIfNull(lastToken); - throwIfNotPostfix(lastToken); - } - - pushOperator(operator); - probableZeroFunction = false; - } - - // numbers and variables - else if (token instanceof Operand || token instanceof Variable) { - postfix.push(token); - probableZeroFunction = false; - } - - // invalid token - else { - throw new Expr4jException("Invalid expression"); - } - - lastToken = token; - } - - // process operator stack - while (!operatorStack.isEmpty()) { - Token token = operatorStack.peek(); - if (token instanceof Function || token instanceof Separator) { - throw new Expr4jException("Unmatched number of parenthesis"); - } - postfix.push(operatorStack.pop()); - } - - return postfix; - } - - /** - * Push operator to operator stack or postfix stack. - * - * @param operator The operator to push - */ - private void pushOperator(Operator operator) { - if (operator.type != OperatorType.PREFIX) { - while (!operatorStack.isEmpty() && - (operatorStack.peek() instanceof Operator && - operator.compareTo((Operator) operatorStack.peek()) > 0)) { - postfix.push(operatorStack.pop()); - } - } - if (operator.type == OperatorType.POSTFIX) { - postfix.push(operator); - } - else { - operatorStack.push(operator); - } - } - - /** - * Method to evaluate operators at the top of the operator stack until a left parenthesis or a function is encountered. - */ - private void evaluateParenthesis() { - boolean flag = false; - - // pop until left parenthesis - while (!operatorStack.isEmpty()) { - Token token = operatorStack.peek(); - - // encountered a function - if (token instanceof Function) { - Function function = (Function) operatorStack.pop(); - - if (functionStack.isEmpty()) { - throw new Expr4jException("Invalid expression"); - } - int actualParameters = functionStack.pop(); - - if (function.parameters == Function.VARIABLE_PARAMETERS) { - function = new Function(function.label, actualParameters, function.operation); - } - else if (function.parameters != actualParameters) { - throw new Expr4jException("Incorrect number of parameters for function: " + function.label); - } - - postfix.push(function); - - flag = true; - break; - } - - // encountered an open bracket - else if (token instanceof Separator) { - operatorStack.pop(); - - if (!operatorStack.isEmpty() && operatorStack.peek() instanceof Operator) { - Operator operator = (Operator) operatorStack.peek(); - if (operator.type == OperatorType.PREFIX) { - postfix.push(operatorStack.pop()); - } - } - - flag = true; - break; - } - - // evaluate top of stack - postfix.push(operatorStack.pop()); - } - - if (!flag) { - throw new Expr4jException("Unmatched number of parenthesis"); - } - } - - /** - * Throw exception if token is null. - * - * @param token The token - */ - private void throwIfNull(Token token) { - if (token == null) { - throw new Expr4jException("Invalid expression"); - } - } - - /** - * Throw exception token is a function. - * - * @param token The token - */ - private void throwIfFunction(Token token) { - if (token instanceof Function) { - throw new Expr4jException("Invalid expression"); - } - } - - /** - * Throw exception if token is an operator but not of type POSTFIX. - * - * @param token The token - */ - private void throwIfNotPostfix(Token token) { - if (token instanceof Operator) { - Operator operator = (Operator) token; - if (operator.type != OperatorType.POSTFIX) { - throw new Expr4jException("Invalid expression"); - } - } - } - - /** - * Throw exception if function is not followed by open bracket. - * - * @param token The token - * @param function The function - */ - private void throwIfNoOpenBracket(Token token, Function function) { - if (token instanceof Separator) { - Separator separator = (Separator) token; - if (separator != Separator.OPEN_BRACKET) { - throw new Expr4jException("Missing open bracket for function: " + function.label); - } - } - else { - throw new Expr4jException("Missing open bracket for function: " + function.label); - } - } - - /** - * Throw exception if the token is an open bracket or a comma. - * - * @param token The token - */ - private void throwIfOpenBracketOrComma(Token token) { - if (token instanceof Separator) { - Separator separator = (Separator) token; - if (separator == Separator.OPEN_BRACKET || separator == Separator.COMMA) { - throw new Expr4jException("Invalid expression"); - } - } - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionTokenizer.java b/src/main/java/com/bitaspire/libs/formula/expression/ExpressionTokenizer.java deleted file mode 100644 index 54e8cf3..0000000 --- a/src/main/java/com/bitaspire/libs/formula/expression/ExpressionTokenizer.java +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.expression; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.token.Function; -import com.bitaspire.libs.formula.token.Operand; -import com.bitaspire.libs.formula.token.Operator; -import com.bitaspire.libs.formula.token.OperatorType; -import com.bitaspire.libs.formula.token.Separator; -import com.bitaspire.libs.formula.token.Token; -import com.bitaspire.libs.formula.token.Variable; - -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * The ExpressionTokenizer<T> class tokenizes expressions independent of the type of operand. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class ExpressionTokenizer { - - /** Expression dictionary */ - private final ExpressionDictionary expressionDictionary; - - /** - * Expression confiuration. - */ - private final ExpressionConfig expressionConfig; - - /** - * Parameterized constructor. - * - * @param expressionDictionary The expression dictionary - * @param expressionConfig The expression configuration - */ - public ExpressionTokenizer(ExpressionDictionary expressionDictionary, - ExpressionConfig expressionConfig) { - this.expressionDictionary = expressionDictionary; - this.expressionConfig = expressionConfig; - } - - /** - * Tokenize an expression. - * - * @param expr The expression - * @return The list of tokens - */ - public List tokenize(String expr) { - // do not allow blank expressions - if (isBlank(expr)) { - throw new Expr4jException("Invalid expression"); - } - - // list of tokens - List tokenList = new ArrayList<>(); - - // initialize patterns - Pattern executablePattern = Pattern.compile(expressionDictionary.getExecutables() - .stream() - .map(Pattern::quote) - .sorted((e1, e2) -> (e2.length() - e1.length())) - .collect(Collectors.joining("|"))); - - Pattern unaryPattern = Pattern.compile("\\+|\\-"); - Pattern separatorPattern = Pattern.compile("\\(|\\)|,"); - Pattern variablePattern = Pattern.compile("[a-zA-Z]+[0-9]*[a-zA-Z]*"); - Pattern whitespacePattern = Pattern.compile("\\s+"); - - List operandPatternList = new ArrayList<>(); - for (String patternString : expressionConfig.getOperandPattern()) { - Pattern operandPattern = Pattern.compile(patternString); - operandPatternList.add(operandPattern); - } - - // initialize parsing variables - int index = 0; - Token lastToken = null; - boolean probableUnary = true; - - // while has more characters - outer: - while (index < expr.length()) { - Matcher matcher; - - // check for separator - matcher = separatorPattern.matcher(expr.substring(index)); - if (matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - Separator separator = Separator.getSeparator(match); - - if (separator == Separator.OPEN_BRACKET) { - addImplicitMultiplication(tokenList, lastToken); - probableUnary = true; - } - else if (separator == Separator.CLOSE_BRACKET) { - probableUnary = false; - } - else { - probableUnary = true; - } - tokenList.add(separator); - - lastToken = separator; - - continue; - } - - // check for unary operators - matcher = unaryPattern.matcher(expr.substring(index)); - if (probableUnary && matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - Operator operator = expressionDictionary.getPrefixOperator(match); - tokenList.add(operator); - - probableUnary = false; - lastToken = operator; - - continue; - } - - // check for executables - matcher = executablePattern.matcher(expr.substring(index)); - if (matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - // encountered a function - if (expressionDictionary.hasFunction(match)) { - Function function = expressionDictionary.getFunction(match); - - addImplicitMultiplication(tokenList, lastToken); - tokenList.add(function); - - probableUnary = false; - lastToken = function; - } - - // encountered an operator - else { - Operator operator; - - if (infixOperatorAllowed(lastToken) && - expressionDictionary.hasInfixOperator(match)) { - operator = expressionDictionary.getInfixOperator(match); - } - else if (postfixOperatorAllowed(lastToken) && - expressionDictionary.hasPostfixOperator(match)) { - operator = expressionDictionary.getPostfixOperator(match); - } - else if (expressionDictionary.hasPrefixOperator(match)) { - operator = expressionDictionary.getPrefixOperator(match); - } - else { - throw new Expr4jException("Undefined symbol: " + match); - } - - if (operator.type == OperatorType.PREFIX) { - addImplicitMultiplication(tokenList, lastToken); - } - tokenList.add(operator); - - probableUnary = operator.type != OperatorType.POSTFIX; - lastToken = operator; - } - - continue; - } - - // check for operands - for (Pattern numberPattern : operandPatternList) { - matcher = numberPattern.matcher(expr.substring(index)); - if (matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - addImplicitMultiplication(tokenList, lastToken); - - Operand operand = new Operand(expressionConfig.stringToOperand(match)); - tokenList.add(operand); - - probableUnary = false; - lastToken = operand; - - continue outer; - } - } - - // check for variables - matcher = variablePattern.matcher(expr.substring(index)); - if (matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - addImplicitMultiplication(tokenList, lastToken); - - Variable variable = new Variable(match); - tokenList.add(variable); - - probableUnary = false; - lastToken = variable; - - continue; - } - - // check for whitespace - matcher = whitespacePattern.matcher(expr.substring(index)); - if (matcher.lookingAt()) { - String match = matcher.group(); - index += match.length(); - - continue; - } - - // invalid character - throw new Expr4jException("Invalid expression"); - } - - return tokenList; - } - - /** - * Check if a string is blank or not. - * - * @param str The string - * @return True if blank, false otherwise - */ - private boolean isBlank(String str) { - return str == null || str.length() == 0 || str.chars().allMatch(Character::isWhitespace); - } - - /** - * Add an implicit multiplication operator to the token list. - * - * @param tokenList The token list - * @param lastToken The last token encountered - */ - private void addImplicitMultiplication(List tokenList, Token lastToken) { - if (lastToken instanceof Operator) { - Operator operator = (Operator) lastToken; - if (operator.type == OperatorType.POSTFIX) { - tokenList.add(expressionDictionary.getInfixOperator("*")); - } - } - else if (lastToken instanceof Separator) { - Separator lastSeparator = (Separator) lastToken; - if (lastSeparator == Separator.CLOSE_BRACKET) { - tokenList.add(expressionDictionary.getInfixOperator("*")); - } - } - else if (lastToken instanceof Operand || lastToken instanceof Variable) { - tokenList.add(expressionDictionary.getInfixOperator("*")); - } - } - - /** - * Check if postfix operator is allowed at current position. - * - * @param lastToken Last token encountered - * @return True if postfix operator is allowed, false otherwise - */ - private boolean postfixOperatorAllowed(Token lastToken) { - if (lastToken == null) { - return false; - } - - if (lastToken instanceof Separator) { - Separator separator = (Separator) lastToken; - return (separator == Separator.CLOSE_BRACKET); - } - else if (lastToken instanceof Operator) { - Operator operator = (Operator) lastToken; - return (operator.type == OperatorType.POSTFIX); - } - else if (lastToken instanceof Operand || lastToken instanceof Variable) { - return true; - } - - return false; - } - - /** - * Check if infix operator is allowed at current position. - * - * @param lastToken Last token encountered - * @return True if infix operator is allowed, false otherwise - */ - private boolean infixOperatorAllowed(Token lastToken) { - if (lastToken == null) { - return false; - } - - if (lastToken instanceof Separator) { - Separator separator = (Separator) lastToken; - return (separator == Separator.CLOSE_BRACKET); - } - else if (lastToken instanceof Operator) { - Operator operator = (Operator) lastToken; - return (operator.type == OperatorType.POSTFIX); - } - else if (lastToken instanceof Operand || lastToken instanceof Variable) { - return true; - } - - return false; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Function.java b/src/main/java/com/bitaspire/libs/formula/token/Function.java deleted file mode 100644 index 4950a46..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Function.java +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.expression.ExpressionParameter; - -import java.util.List; - -/** - * The Function<T> class represents functions in the expression. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class Function implements Token { - - /** - * Constant to indicate that the function supports variable number of parameters. - */ - public static final int VARIABLE_PARAMETERS = -1; - - /** - * Label of the function. - */ - public final String label; - - /** - * Number of parameters. - */ - public final int parameters; - - /** - * Operation performed by the function. - */ - public final Operation operation; - - /** - * Parameterized constructor. - * - * @param label Label of the function - * @param parameters Number of parameters - * @param operation Operation performed by the function - */ - public Function(String label, int parameters, Operation operation) { - this.label = label; - this.parameters = parameters; - this.operation = operation; - - if (this.parameters < Function.VARIABLE_PARAMETERS) { - throw new Expr4jException("Invalid number of parameters: " + this.parameters); - } - } - - /** - * Parameterized constructor.
- * This constructor creates a function with variable number of parameters. - * - * @param label Label of the function - * @param operation Operation performed by the function - */ - public Function(String label, Operation operation) { - this(label, VARIABLE_PARAMETERS, operation); - } - - /** - * Evaluate the function lazily. - * - * @param parameters List of parameters - * @return Evaluated result - */ - public T evaluate(List> parameters) { - return this.operation.execute(parameters); - } - - @Override - public String toString() { - return "Function{" + - "label='" + label + '\'' + - ", parameters=" + parameters + - '}'; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Operand.java b/src/main/java/com/bitaspire/libs/formula/token/Operand.java deleted file mode 100644 index 6c3e6ab..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Operand.java +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -/** - * The Operand<T> class represents operands in the expression.
- * It acts as a wrapper for value of type T. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class Operand implements Token { - - /** - * Value of the operand. - */ - public final T value; - - /** - * Parameterized constructor. - * - * @param value Value of the operand - */ - public Operand(T value) { - this.value = value; - } - - @Override - public String toString() { - return value.toString(); - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Operation.java b/src/main/java/com/bitaspire/libs/formula/token/Operation.java deleted file mode 100644 index 910f89d..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Operation.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.bitaspire.libs.formula.token; - -import com.bitaspire.libs.formula.expression.ExpressionParameter; - -import java.util.List; - -/** - * The Operation<T> interface represents an operation that can be executed. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public interface Operation { - - /** - * Execute the operation. - * - * @param parameters List of parameters - * @return Evaluated result - */ - T execute(List> parameters); - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Operator.java b/src/main/java/com/bitaspire/libs/formula/token/Operator.java deleted file mode 100644 index 436d6e4..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Operator.java +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -import com.bitaspire.libs.formula.exception.Expr4jException; -import com.bitaspire.libs.formula.expression.ExpressionParameter; - -import java.util.List; - -/** - * The Operator<T> class represents operators in the expression. - * - * @author Pratanu Mandal - * @since 1.0 - * - * @param The type of operand - */ -public class Operator implements Token, Comparable> { - - /** - * Label of the operator. - */ - public final String label; - - /** - * The type of operator. - */ - public final OperatorType type; - - /** - * The precedence of this operator.
- * Precedence ranges from 1 to MAX_INT, in ascending order, i.e, with 1 being lowest precedence possible. - */ - public final int precedence; - - /** - * Operation performed by the operation. - */ - public final Operation operation; - - /** - * Parameterized constructor. - * - * @param label Label of the operator - * @param operatorType Type of the operator - * @param precedence Precedence of the operator - * @param operation Operation performed by the operator - */ - public Operator(String label, OperatorType operatorType, int precedence, Operation operation) { - this.label = label; - this.type = operatorType; - this.precedence = precedence; - this.operation = operation; - - if (this.precedence < 1) { - throw new Expr4jException("Invalid precedence: " + this.precedence); - } - } - - /** - * Evaluate the function lazily. - * - * @param parameters List of parameters - * @return Evaluated result - */ - public T evaluate(List> parameters) { - return this.operation.execute(parameters); - } - - /** - * Method to compare this operator to another operator on the basis of precedence. - */ - @Override - public int compareTo(Operator other) { - // compare the precedences and associativity of the two operators - return (this.precedence == other.precedence) ? - (this.type == OperatorType.INFIX || this.type == OperatorType.POSTFIX ? 1 : -1) - : other.precedence - this.precedence; - } - - @Override - public String toString() { - return "Operator{" + - "label='" + label + '\'' + - ", type=" + type + - ", precedence=" + precedence + - '}'; - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/OperatorType.java b/src/main/java/com/bitaspire/libs/formula/token/OperatorType.java deleted file mode 100644 index 00e54be..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/OperatorType.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -/** - * The OperatorType enum represents the type of an operator.
- * It can be of four types: PREFIX, POSTFIX, INFIX, and INFIX_RTL.

- * - * PREFIX - must be applied before an operand, right associative.
- * POSTFIX - must be applied after an operand, left associative.
- * INFIX - must be applied in between two operands, left associative.
- * INFIX_RTL - must be applied in between two operands, right associative. - * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public enum OperatorType { - /** Prefix operators */ - PREFIX, - - /** Postfix operators */ - POSTFIX, - - /** Infix operators (left to right) */ - INFIX, - - /** Infix operators (right to left) */ - INFIX_RTL -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Separator.java b/src/main/java/com/bitaspire/libs/formula/token/Separator.java deleted file mode 100644 index d39cf37..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Separator.java +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -import com.bitaspire.libs.formula.exception.Expr4jException; - -/** - * The Separator class represents the separators in the expression.
- * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public enum Separator implements Token { - - /** Open bracket */ - OPEN_BRACKET("("), - - /** Close bracket */ - CLOSE_BRACKET(")"), - - /** Comma */ - COMMA(","); - - /** - * Label of the separator. - */ - private final String label; - - /** - * Parameterized constructor. - * - * @param label Label of the separator. - */ - Separator(String label) { - this.label = label; - } - - /** - * Get the separator label. - * - * @return The label - */ - public String label() { - return label; - } - - @Override - public String toString() { - return "Separator{" + - "name='" + name() + '\'' + - ", label='" + label() + '\'' + - '}'; - } - - /** - * Get the separator with specified label. - * - * @param label The label - * @return The separator - */ - public static Separator getSeparator(String label) { - switch (label) { - case "(": return OPEN_BRACKET; - case ")": return CLOSE_BRACKET; - case ",": return COMMA; - default: throw new Expr4jException("Invalid separator"); - } - } - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Token.java b/src/main/java/com/bitaspire/libs/formula/token/Token.java deleted file mode 100644 index bd8b8d8..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Token.java +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -/** - * The Token interface represents any token in expressions.

- * A token is the smallest indivisible unit of any expression.
- * Tokens can be operands, functions, operators, separators, variables, or constants. - * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public interface Token { - -} diff --git a/src/main/java/com/bitaspire/libs/formula/token/Variable.java b/src/main/java/com/bitaspire/libs/formula/token/Variable.java deleted file mode 100644 index 00154d3..0000000 --- a/src/main/java/com/bitaspire/libs/formula/token/Variable.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright 2023 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.bitaspire.libs.formula.token; - -/** - * The Variable class represents the variables in the expression. - * - * @author Pratanu Mandal - * @since 1.0 - * - */ -public class Variable implements Token { - - /** - * Label of the variable. - */ - public final String label; - - /** - * Parameterized constructor. - * - * @param label Label of the variable - */ - public Variable(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - -} diff --git a/src/main/java/net/zerotoil/dev/cyberlevels/api/events/XPChangeEvent.java b/src/main/java/net/zerotoil/dev/cyberlevels/api/events/XPChangeEvent.java new file mode 100644 index 0000000..4a1e95e --- /dev/null +++ b/src/main/java/net/zerotoil/dev/cyberlevels/api/events/XPChangeEvent.java @@ -0,0 +1,85 @@ +package net.zerotoil.dev.cyberlevels.api.events; + +import lombok.Getter; +import lombok.Setter; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Legacy compatibility event preserved for older integrations. + * + *

This event mirrors the historical API exposed by earlier CyberLevels releases. New code should + * migrate to {@link com.bitaspire.cyberlevels.event.ExpChangeEvent}, which exposes richer context + * such as old/new levels and the resolved {@code LevelUser}. + * + * @deprecated Use {@link com.bitaspire.cyberlevels.event.ExpChangeEvent} instead. + */ +@Deprecated +@Getter +public class XPChangeEvent extends Event { + + private static final HandlerList handlerList = new HandlerList(); + + /** + * Player whose EXP is being modified. + */ + private final Player player; + /** + * EXP value before the pending change is applied. + */ + private final double oldXP; + /** + * Mutable EXP delta that legacy listeners may adjust. + */ + @Setter + private double amount; + + /** + * Creates a legacy EXP change event snapshot. + * + * @param player affected player + * @param oldXP EXP value before the change + * @param amount mutable EXP delta that will be applied + */ + public XPChangeEvent(@NotNull Player player, double oldXP, double amount) { + super(!Bukkit.isPrimaryThread()); + + this.player = player; + this.oldXP = oldXP; + this.amount = amount; + } + + /** + * Returns the Bukkit handler list for this legacy event type. + * + * @return handler list required by the Bukkit event contract + */ + public static HandlerList getHandlerList() { + return handlerList; + } + + /** + * Returns the projected EXP after applying the current delta in one step. + * + *

This value is informational only. CyberLevels may still perform additional level-up logic + * after the delta is processed. + * + * @return projected EXP value based on the current {@link #getAmount()} + */ + public double getNewXP() { + return oldXP + amount; + } + + /** + * Returns the Bukkit handler list for this event instance. + * + * @return handler list required by the Bukkit event contract + */ + @NotNull + public HandlerList getHandlers() { + return handlerList; + } +} diff --git a/src/main/resources/addons/levelled-mobs.yml b/src/main/resources/addons/levelled-mobs.yml index c09ea32..a2499ff 100644 --- a/src/main/resources/addons/levelled-mobs.yml +++ b/src/main/resources/addons/levelled-mobs.yml @@ -20,6 +20,7 @@ earn-exp: specific-animals: # These WILL stack on the ones above. enabled: false + stack-with-general: true animals: - '3:COW-1,6: 1, 2' - 'SHEEP: 1, 2' @@ -41,7 +42,8 @@ earn-exp: specific-monsters: # These WILL stack on the ones above. enabled: false + stack-with-general: true min-level: 2 monsters: - 'SPIDER: 1, 2' - - 'CREEPER: 1, 2' \ No newline at end of file + - 'CREEPER: 1, 2' diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4446292..5facc30 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -2,8 +2,8 @@ config: # Should data be stored in MySQL? # Tested on MySQL 10.4 and MariaDB 10.6. mysql: - enabled: true - type: SQLITE # Can also be MARIADB, SQLITE, POSTGRES, POSTGRESQL + enabled: true # When enabled, will use type below. When disabled, this will fall-back to file storage. + type: SQLITE # Can also be MARIADB, SQLITE, POSTGRES, POSTGRESQL, H2 host: 'localhost' port: '3306' database: 'cyberlevels' @@ -12,7 +12,13 @@ config: table: 'levels' ssl: true sqlite-file: "plugins/CyberLevels/data.db" + # Embedded H2 database file (used when type: H2). H2 will append its own + # extension (.mv.db) to the path below. AUTO_SERVER mode is enabled so + # multiple JVM processes on the same host can share the file. + h2-file: "plugins/CyberLevels/data.h2" + # Should the plugin use BigDecimal for all calculations? + # Can be required if you plan to use very large numbers, otherwise keep disabled. use-big-decimal-system: false # Should numbers be rounded? @@ -34,6 +40,10 @@ config: # continue using at your own discretion. enabled: true + # How many top ranks to keep in memory and expose (e.g. /clv top, placeholders). + # Higher values can increase work per update and lag the server more. + max-positions: 10 + # Should the leaderboard sync with your storage # system when the auto-save is called? sync-on-auto-save: true @@ -47,6 +57,11 @@ config: # How often (in seconds)? interval: 300 + tab-complete: + # Load offline player data when suggesting names? + # If false, will suggest online users only. + load-offline-users: true + # Should adding levels give rewards? add-level-reward: false @@ -68,6 +83,14 @@ config: lang: true earn-exp: true + # Contacts SpigotMC once on startup (async) to compare your JAR + # version with the resource listing (resource ID 98826). + spigot-update-check: + enabled: true + + # When an update or early-access notice is shown in console, also message online operators in chat. + notify-ops-chat: true + messages: # Should auto-save messages send to console? auto-save: true diff --git a/src/main/resources/earn-exp.yml b/src/main/resources/earn-exp.yml index e266642..523ce40 100644 --- a/src/main/resources/earn-exp.yml +++ b/src/main/resources/earn-exp.yml @@ -16,6 +16,7 @@ earn-exp: specific-permissions: # These WILL stack on the ones above. enabled: false + stack-with-general: true permissions: - 'permission.vip: 2, 4' - 'permission.mvp: 3, 5' @@ -35,6 +36,7 @@ earn-exp: # These WILL stack on the ones above. Could be # used to reduce EXP loss for special users. enabled: false + stack-with-general: true permissions: - 'permission.vip: 1, 2' - 'permission.mvp: 2, 3' @@ -54,6 +56,7 @@ earn-exp: specific-players: # These WILL stack on the ones above. enabled: false + stack-with-general: true players: - 'CroaBeast: 2, 4' - 'ThiccPickle: 3, 5' @@ -73,6 +76,7 @@ earn-exp: specific-animals: # These WILL stack on the ones above. enabled: false + stack-with-general: true animals: - 'COW: 1, 2' - 'SHEEP: 1, 2' @@ -93,6 +97,7 @@ earn-exp: specific-monsters: # These WILL stack on the ones above. enabled: false + stack-with-general: true monsters: - 'SPIDER: 1, 2' - 'CREEPER: 1, 2' @@ -112,6 +117,7 @@ earn-exp: specific-players: # These WILL stack on the ones above. enabled: false + stack-with-general: true players: - 'CroaBeast: 2, 4' - 'ThiccPickle: 3, 5' @@ -131,6 +137,7 @@ earn-exp: specific-animals: # These WILL stack on the ones above. enabled: false + stack-with-general: true animals: - 'COW: 1, 2' - 'SHEEP: 1, 2' @@ -151,6 +158,7 @@ earn-exp: specific-monsters: # These WILL stack on the ones above. enabled: false + stack-with-general: true monsters: - 'SPIDER: 1, 2' - 'CREEPER: 1, 2' @@ -170,6 +178,7 @@ earn-exp: - "NETHERRACK" specific-blocks: enabled: false + stack-with-general: true blocks: - 'DIAMOND_ORE: 4, 6' @@ -188,6 +197,7 @@ earn-exp: - "NETHERRACK" specific-blocks: enabled: false + stack-with-general: true blocks: - 'DIAMOND_ORE: 4, 6' @@ -205,6 +215,7 @@ earn-exp: - "MUTTON" specific-items: enabled: false + stack-with-general: true items: - 'GOLDEN_APPLE: 4, 6' @@ -219,6 +230,7 @@ earn-exp: - 'permission.no.exp' specific-permissions: enabled: false + stack-with-general: true permissions: - 'permission.vip: 1, 2' - 'permission.mvp: 2, 3' @@ -236,6 +248,7 @@ earn-exp: - "WOODEN_PICKAXE" specific-items: enabled: false + stack-with-general: true items: - 'DIAMOND_BLOCK: 10, 15' @@ -251,6 +264,7 @@ earn-exp: - "HUNGER" specific-potions: enabled: false + stack-with-general: true potions: - 'SPEED: 1, 3' - 'HEAL: 1, 2' @@ -268,6 +282,7 @@ earn-exp: - "UNBREAKING-2" specific-enchantments: enabled: false + stack-with-general: true enchantments: - 'KNOCKBACK-1: 1, 3' - 'LURE: 1, 2' @@ -286,6 +301,7 @@ earn-exp: - "STRING" specific-fish: enabled: false + stack-with-general: true fish: - 'COD: 2, 3' - 'SALMON: 2, 3' @@ -302,6 +318,7 @@ earn-exp: - "CHICKEN" specific-animals: enabled: false + stack-with-general: true animals: - 'SHEEP: 2, 3' - 'DOG: 2, 3' @@ -321,6 +338,7 @@ earn-exp: - "oh hi mark" specific-words: enabled: false + stack-with-general: true words: - 'HELLO: 1, 2' - 'AWESOME: 1, 2' @@ -342,6 +360,7 @@ earn-exp: - 2 specific-amounts: enabled: false + stack-with-general: true # If a player earns 5 via vanilla system, they # will earn between 2 and 3 EXP in CLV for the # first example below. @@ -363,6 +382,7 @@ earn-exp: - "BEET_ROOT" specific-blocks: enabled: false + stack-with-general: true blocks: - 'CARROT: 4, 6' @@ -379,5 +399,45 @@ earn-exp: - "GRASS_BLOCK" specific-blocks: enabled: false + stack-with-general: true blocks: - - 'DIAMOND_ORE: 4, 6' \ No newline at end of file + - 'DIAMOND_ORE: 4, 6' + + # Used for AxHoes (Artillex Studios). Triggered by AxHoes' own PlayerXPGainEvent + # (fired when AxHoes confirms a successful break with one of its tools), NOT by + # the vanilla BlockBreakEvent — AxHoes cancels that and runs its own break logic. + # Per-block include/specific lists therefore have no effect; only the general + # range applies. Set general.exp to choose the CyberLevels XP reward per break. + axhoes-breaking: + general: + enabled: false + exp: 1 + includes: + enabled: false + whitelist: false + list: + - "POTATO" + - "BEET_ROOT" + specific-blocks: + enabled: false + stack-with-general: true + blocks: + - 'CARROT: 4, 6' + + # Used for AxPickaxes (Artillex Studios). Same caveats as axhoes-breaking above: + # triggered by AxPickaxes' PlayerXPGainEvent, only the general range is used. + axpick-breaking: + general: + enabled: false + exp: 1 + includes: + enabled: false + whitelist: false + list: + - "DIRT" + - "GRASS_BLOCK" + specific-blocks: + enabled: false + stack-with-general: true + blocks: + - 'DIAMOND_ORE: 4, 6' diff --git a/src/main/resources/lang.yml b/src/main/resources/lang.yml index de54726..6235807 100644 --- a/src/main/resources/lang.yml +++ b/src/main/resources/lang.yml @@ -53,6 +53,12 @@ messages: top-header: '[C] &8&m―――――――&8<&d&l Top &f&lPlayers &8>&8&m―――――――' top-content: '&f[{position}] &d{player}&7: &7level: &f{level}&7, exp: &f{exp}' top-footer: '[C] &8&m――――――――――――――――――――――――――――――――' + spigot-update-newer-chat: + - '[C] &7A newer version is listed on Spigot: &d{remoteVersion}&7 (you are on &f{localVersion}&7).' + - '[C] &7Download: &f{resourceUrl}' + spigot-update-early-access-chat: + - '[C] &7Early access build (&f{localVersion}&7).' + - '[C] &7If you encounter issues, report on Discord: &d{discordUrl}' leaderboard-placeholders: no-player-name: '&c-' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c88f376..f680b0b 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,11 +3,11 @@ version: '${version}' main: com.bitaspire.cyberlevels.CyberLevels api-version: 1.13 prefix: CLV -softdepend: [ PlaceholderAPI, Vault, CyberWorldReset, RivalHarvesterHoes, RivalPickaxes ] authors: [ Kihsomray, CroaBeast ] +contributors: [ Klema_LP ] description: A leveling system plugin -website: bitaspire.com folia-supported: true +website: https://wiki.bitaspire.com/en/docs/cyberlevels commands: clv: @@ -20,10 +20,21 @@ commands: - lvl - lvls +softdepend: + - PlaceholderAPI + - Vault + - CyberWorldReset + - RivalHarvesterHoes + - RivalPickaxes + - AxBoosters + - AxHoes + - AxPickaxes + libraries: - ch.obermuhlner:big-math:2.3.2 - - com.zaxxer:HikariCP:3.4.5 - - mysql:mysql-connector-java:8.0.21 - - org.xerial:sqlite-jdbc:3.36.0.3 - - org.postgresql:postgresql:42.7.7 + - com.zaxxer:HikariCP:7.0.2 + - com.mysql:mysql-connector-j:9.5.0 + - org.xerial:sqlite-jdbc:3.51.1.0 + - org.postgresql:postgresql:42.7.8 + - com.h2database:h2:2.3.232 - org.apache.commons:commons-lang3:3.18.0 \ No newline at end of file