From a54a3487a49c82d7b95bdce1f9f584b75056c8cc Mon Sep 17 00:00:00 2001 From: robinhickmann Date: Tue, 14 Oct 2025 12:50:10 +0200 Subject: [PATCH 1/8] feat: add alerts for players joining a paused dimension --- .../dimensionpause/DimensionState.java | 36 ++++++++++++------- .../events/ListenerManager.java | 1 + .../events/PlayerJoinEventListener.java | 28 +++++++++++++++ .../PlayerSpawnLocationEventListener.java | 2 ++ .../events/PlayerTeleportEventListener.java | 13 +------ 5 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionState.java b/src/main/java/org/reprogle/dimensionpause/DimensionState.java index ab1d09c..f512a41 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionState.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionState.java @@ -9,12 +9,17 @@ import java.io.IOException; import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; import java.util.logging.Level; import org.bukkit.Location; public class DimensionState { + public static final Set alertPlayers = new HashSet<>(); + // Suppress ConstantValue warning for netherPause and endPaused, because that's not true due to #toggleDimension public DimensionState(Plugin plugin) { boolean netherState = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.paused"); @@ -74,10 +79,11 @@ public void toggleDimension(World.Environment dimension) { @Nullable public Location kickToWorld(Player player, World.Environment dimension, boolean teleport) { + Location bedSpawn = player.getBedSpawnLocation(); Location loc; - if (ConfigManager.getPluginConfig().getBoolean("try-bed-first") && player.getBedSpawnLocation() != null) { - if (teleport) player.teleport(player.getBedLocation()); + if (ConfigManager.getPluginConfig().getBoolean("try-bed-first") && bedSpawn != null) { + if (teleport) player.teleport(bedSpawn); loc = player.getBedSpawnLocation(); } else { World world = Bukkit.getWorld(ConfigManager.getPluginConfig().getString("kick-world")); @@ -91,17 +97,7 @@ public Location kickToWorld(Player player, World.Environment dimension, boolean } if (teleport) { - // Send the player the proper title for the environment they tried to access - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions." + (dimension.equals(World.Environment.NETHER) ? "nether" : "end") + ".alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions." + (dimension.equals(World.Environment.NETHER) ? "nether" : "end") + ".alert.chat.enabled"); - - if (sendTitle) { - player.showTitle(CommandFeedback.getTitleForDimension(dimension)); - } - - if (sendChat) { - player.sendMessage(CommandFeedback.getChatForDimension(dimension)); - } + alertPlayer(player, dimension); } return loc; @@ -121,6 +117,20 @@ public boolean canBypass(Player player, boolean bypassableFlag) { return player.hasPermission("dimensionpause.bypass"); } + public void alertPlayer(Player player, World.Environment dimension) { + String env = dimension.equals(World.Environment.NETHER) ? "nether" : "end"; + boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.title.enabled"); + boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.chat.enabled"); + + if (sendTitle) { + player.showTitle(CommandFeedback.getTitleForDimension(dimension)); + } + + if (sendChat) { + player.sendMessage(CommandFeedback.getChatForDimension(dimension)); + } + } + private void alertOfStateChange(Collection players, World.Environment environment, boolean newState) { // Get a string value for the dimension. This is useful later on. String env = environment.equals(World.Environment.NETHER) ? "nether" : "end"; diff --git a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java index e6bda9a..27bbaac 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java +++ b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java @@ -11,6 +11,7 @@ public class ListenerManager { */ public static void setupListeners(Plugin plugin) { plugin.getServer().getPluginManager().registerEvents(new PlayerSpawnLocationEventListener(), plugin); + plugin.getServer().getPluginManager().registerEvents(new PlayerJoinEventListener(), plugin); plugin.getServer().getPluginManager().registerEvents(new PlayerTeleportEventListener(), plugin); plugin.getServer().getPluginManager().registerEvents(new PlayerInteractEventListener(), plugin); plugin.getServer().getPluginManager().registerEvents(new PortalCreateEventListener(), plugin); diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java new file mode 100644 index 0000000..953e17a --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java @@ -0,0 +1,28 @@ +package org.reprogle.dimensionpause.events; + +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; + +public class PlayerJoinEventListener implements Listener { + + @EventHandler(priority = EventPriority.HIGHEST) + public static void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + + if (!DimensionState.alertPlayers.contains(player.getUniqueId())) { + return; + } + + World world = player.getWorld(); + + DimensionPausePlugin.ds.alertPlayer(player, world.getEnvironment()); + DimensionState.alertPlayers.remove(player.getUniqueId()); + } + +} diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java index 712cfba..3ec075d 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java @@ -7,6 +7,7 @@ import org.bukkit.event.Listener; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; import org.spigotmc.event.player.PlayerSpawnLocationEvent; public class PlayerSpawnLocationEventListener implements Listener{ @@ -37,6 +38,7 @@ public static void onPlayerSpawn(PlayerSpawnLocationEvent event) { if (location != null) { event.setSpawnLocation(location); + DimensionState.alertPlayers.add(event.getPlayer().getUniqueId()); } } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java index 2257062..b90f1e8 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java @@ -8,7 +8,6 @@ import org.bukkit.event.player.PlayerTeleportEvent; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; -import org.reprogle.dimensionpause.commands.CommandFeedback; public class PlayerTeleportEventListener implements Listener { @@ -38,17 +37,7 @@ public static void onPlayerTeleport(PlayerTeleportEvent event) { event.setCancelled(true); // Send the player the proper title for the environment they tried to access - String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions." + environment + ".alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions." + environment + ".alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(CommandFeedback.getTitleForDimension(env)); - } - - if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(env)); - } + DimensionPausePlugin.ds.alertPlayer(p, env); } } From 9d72e8467253cb5c0656f4b10ac5dc14b50ea2ce Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sun, 19 Oct 2025 16:46:51 -0500 Subject: [PATCH 2/8] Use Java 21, bStats charts, Gradle conversion, GHA Implement GitHub actions to release to Modrinth and Hangar automatically Note that both Modrinth AND PaperMC documentation is wrong when it comes to their APIs You MUST implement certain fields both APIs deem optional. Also, Hangar requires its own custom auth on top of already-existing API keys...??? No idea what the decision was behind that, but okay. It's like they're mixing client credentials with authorization code, and it makes no sense. --- .github/workflows/build.yml | 94 +++++++ .github/workflows/publish-to-hangar.sh | 93 +++++++ .github/workflows/publish-to-modrinth.sh | 79 ++++++ .gitignore | 6 + README.md | 3 - build.gradle.kts | 81 ++++++ gradle.properties | 5 + gradle/libs.versions.toml | 14 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ pom.xml | 117 -------- settings.gradle.kts | 5 + .../reprogle/dimensionpause/DPMetrics.java | 25 ++ .../dimensionpause/DimensionPausePlugin.java | 97 ++++--- .../dimensionpause/UpdateChecker.java | 57 ++-- .../commands/CommandFeedback.java | 2 +- src/main/resources/plugin.yml | 2 +- 19 files changed, 848 insertions(+), 184 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish-to-hangar.sh create mode 100644 .github/workflows/publish-to-modrinth.sh create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat delete mode 100644 pom.xml create mode 100644 settings.gradle.kts create mode 100644 src/main/java/org/reprogle/dimensionpause/DPMetrics.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0c13592 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,94 @@ +name: Build and Publish +on: + push: + branches-ignore: + - main + release: + types: [ published ] + +jobs: + build: + runs-on: ubuntu-latest + outputs: + jar: ${{ steps.artifact-upload.outputs.artifact-id }} + steps: + - name: Checkout sources + uses: actions/checkout@v5 + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + cache: gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Build Snapshot + if: ${{ github.event_name == 'push' }} + run: ./gradlew build + - name: Build Release + if: ${{ github.event_name == 'release' }} + run: ./gradlew build -PreleaseBuild=true + - name: Upload build artifact + uses: actions/upload-artifact@v4 + id: artifact-upload + with: + name: build-output + path: build/libs/*.jar + if-no-files-found: 'error' + + publish-snapshot: + runs-on: ubuntu-latest + needs: build + if: ${{ github.event_name == 'push' }} + steps: + - uses: actions/checkout@v4 + - name: Download build artifact + uses: actions/download-artifact@v5 + with: + artifact-ids: ${{ needs.build.outputs.jar }} + path: ./dist + + - name: Determine Version + id: version-meta + run: | + echo "version=$(echo "$BASENAME" | sed -E 's/^[^-]+-(.+)\.jar$/\1/')" >> $GITHUB_OUTPUT + + - name: Publish Snapshot to Modrinth + env: + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + PROJECT_ID: 4dRudYCe + TITLE: "${{ steps.version-meta.outputs.version }}" + CHANGELOG: "Generated automatically from commit ${{ github.sha }}" + TYPE: "alpha" + run: | + chmod +x ./.github/workflows/publish-to-modrinth.sh + ./.github/workflows/publish-to-modrinth.sh + + + - name: Publish Snapshot to Hangar + env: + HANGAR_TOKEN: ${{ secrets.HANGAR_TOKEN }} + PROJECT_SLUG: 3265 + TITLE: "${{ steps.version-meta.outputs.version }}" + DESCRIPTION: "Generated automatically from commit ${{ github.sha }}" + CHANNEL: "Snapshot" + run: | + chmod +x ./.github/workflows/publish-to-hangar.sh + ./.github/workflows/publish-to-hangar.sh + + + publish-release: + runs-on: ubuntu-latest + needs: build + if: ${{ github.event_name == 'release' }} + steps: + - uses: actions/checkout@v4 + - name: Download build artifact + uses: actions/download-artifact@v5 + with: + artifact-ids: ${{ needs.build.outputs.jar }} + path: ./dist + + - name: Publish Release + run: | + echo "Publishing release..." \ No newline at end of file diff --git a/.github/workflows/publish-to-hangar.sh b/.github/workflows/publish-to-hangar.sh new file mode 100644 index 0000000..bb692bc --- /dev/null +++ b/.github/workflows/publish-to-hangar.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# === โš™๏ธ Config === +PROJECT_SLUG="${PROJECT_SLUG:-}" +HANGAR_TOKEN="${HANGAR_TOKEN:-}" +CHANNEL="${CHANNEL:-Snapshot}" # Release | Snapshot | Beta +TITLE="${TITLE:-}" +DESCRIPTION="${DESCRIPTION:-}" +DIST_DIR="${DIST_DIR:-dist}" + +# === ๐Ÿ” Validation === +if [ -z "$HANGAR_TOKEN" ]; then + echo "โŒ Missing HANGAR_TOKEN environment variable." + exit 1 +fi +if [ -z "$PROJECT_SLUG" ]; then + echo "โŒ Missing PROJECT_SLUG environment variable." + exit 1 +fi + +FILE=$(ls "$DIST_DIR"/*.jar 2>/dev/null | head -n 1 || true) +if [ -z "$FILE" ]; then + echo "โŒ Could not find .jar file in '$DIST_DIR/'." + exit 1 +fi + +# === ๐Ÿงฎ Version extraction === +BASENAME=$(basename "$FILE") +VERSION=$(echo "$BASENAME" | sed -E 's/^[^-]+-(.+)\.jar$/\1/') +DATE=$(date -u +"%Y-%m-%d %H:%M UTC") + +if [ -z "$TITLE" ]; then + TITLE="Automated Upload $VERSION" +fi +if [ -z "$DESCRIPTION" ]; then + DESCRIPTION="Automated upload from commit ${GITHUB_SHA:-unknown} on $DATE." +fi + +echo "๐Ÿ“ฆ Preparing Hangar upload..." +echo " Project : $PROJECT_SLUG" +echo " Version : $VERSION" +echo " Channel : $CHANNEL" +echo " File : $FILE" + +# === ๐Ÿ”‘ Authenticate === +AUTH_RESPONSE=$(curl -sfS -X POST "https://hangar.papermc.io/api/v1/authenticate?apiKey=$HANGAR_TOKEN") +TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "โŒ Failed to obtain JWT token from Hangar." + echo "$AUTH_RESPONSE" + exit 1 +fi + +# === ๐Ÿงฑ Version metadata === +cat > versionUpload.json </dev/null || echo "unknown") +echo "โœ… Upload complete!" +echo "๐ŸŒ Version URL: ${URL}" diff --git a/.github/workflows/publish-to-modrinth.sh b/.github/workflows/publish-to-modrinth.sh new file mode 100644 index 0000000..d6cd415 --- /dev/null +++ b/.github/workflows/publish-to-modrinth.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# === โš™๏ธ Config === +MODRINTH_TOKEN="${MODRINTH_TOKEN:-}" +PROJECT_ID="${PROJECT_ID:-}" +DIST_DIR="${DIST_DIR:-dist}" +TITLE="${TITLE:-}" +CHANGELOG="${CHANGELOG:-}" +TYPE="${TYPE:-alpha}" # release | beta | alpha + +# === ๐Ÿ” Validation === +if [ -z "$MODRINTH_TOKEN" ]; then + echo "โŒ Missing MODRINTH_TOKEN environment variable." + exit 1 +fi +if [ -z "$PROJECT_ID" ]; then + echo "โŒ Missing PROJECT_ID environment variable." + exit 1 +fi + +FILE=$(ls "$DIST_DIR"/*.jar 2>/dev/null | head -n 1 || true) +if [ -z "$FILE" ]; then + echo "โŒ Could not find any .jar file in '$DIST_DIR/'." + exit 1 +fi +FILE_NAME=$(basename "$FILE") + +# === ๐Ÿงฎ Version extraction === +VERSION=$(echo "$FILE_NAME" | sed -E 's/^[^-]+-(.+)\.jar$/\1/') +DATE=$(date -u +"%Y-%m-%d %H:%M UTC") + +# === ๐Ÿงพ Metadata generation === +if [ -z "$TITLE" ]; then + TITLE="Automated Build $VERSION" +fi +if [ -z "$CHANGELOG" ]; then + CHANGELOG="Build generated automatically from commit ${GITHUB_SHA:-unknown} on $DATE." +fi + +echo "๐Ÿงพ Preparing Modrinth metadata..." +echo " File: $FILE_NAME" +echo " Version: $VERSION" +echo " Type: $TYPE" +echo " Title: $TITLE" + +# === ๐Ÿงฑ Create metadata.json === +cat > metadata.json < Modrinth - - Polymart - Hangar diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..71108fe --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,81 @@ +import com.ctc.wstx.shaded.msv_core.datatype.xsd.datetime.TimeZone +import org.apache.tools.ant.filters.ReplaceTokens +import org.gradle.internal.impldep.org.joda.time.tz.UTCProvider +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +plugins { + java + id("com.gradleup.shadow") version "9.2.2" +} + +project.group = "org.reprogle" +project.version = "1.2.0" +project.description = "Allows you to pause dimensions to prevent players from entering them" + +val isReleaseBuild = project.hasProperty("releaseBuild") +val forceBuildId = project.hasProperty("forceBuildId") + +if (!isReleaseBuild || forceBuildId) { + val timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")) + val newVersion = "${project.version}-SNAPSHOT-${timestamp}" + project.version = newVersion + println("Auto build ID enabled โ†’ version set to $newVersion") +} else { + println("Release build โ†’ using version ${project.version}") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +repositories { + maven { + name = "papermc" + url = uri("https://repo.papermc.io/repository/maven-public/") + } + maven { + name = "sonatype" + url = uri("https://oss.sonatype.org/content/groups/public/") + } + mavenCentral() +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.17.1-R0.1-SNAPSHOT") + implementation("net.kyori:adventure-text-minimessage:4.17.0") + implementation("dev.dejvokep:boosted-yaml:1.3") + implementation("org.bstats:bstats-bukkit:3.0.2") +} + +tasks.withType { + options.encoding = "UTF-8" +} + +tasks.processResources { + outputs.upToDateWhen { false } + from(sourceSets.main.get().resources.srcDirs) { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + filter( + "tokens" to mapOf( + "version" to project.version.toString(), + ) + ) + } +} + +tasks.shadowJar { + archiveClassifier.set("") // Replace the normal JAR + mergeServiceFiles() + exclude("META-INF/*.MF") + + relocate("dev.dejvokep.boostedyaml", "org.reprogle.dimensionpause.libs") + relocate("org.bstats", "org.reprogle.dimensionpause.libs") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..377538c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..78f1a75 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,14 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +dev-dejvokep-boosted-yaml = "1.3" +io-papermc-paper-paper-api = "1.17.1-R0.1-SNAPSHOT" +net-kyori-adventure-text-minimessage = "4.17.0" +org-bstats-bstats-bukkit = "3.0.2" + +[libraries] +dev-dejvokep-boosted-yaml = { module = "dev.dejvokep:boosted-yaml", version.ref = "dev-dejvokep-boosted-yaml" } +io-papermc-paper-paper-api = { module = "io.papermc.paper:paper-api", version.ref = "io-papermc-paper-paper-api" } +net-kyori-adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "net-kyori-adventure-text-minimessage" } +org-bstats-bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "org-bstats-bstats-bukkit" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +: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" %* + +: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 diff --git a/pom.xml b/pom.xml deleted file mode 100644 index 1941104..0000000 --- a/pom.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - 4.0.0 - - org.reprogle - DimensionPause - 1.1.2 - jar - - DimensionPause - - - - Mozilla Public License 2.0 - https://www.mozilla.org/en-US/MPL/2.0/ - - - - Allows you to pause dimensions to prevent players from entering them - - 17 - UTF-8 - - - - clean package - ${basedir}/src/main/java - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - ${java.version} - ${java.version} - - - - org.apache.maven.plugins - maven-shade-plugin - 3.3.0 - - - - *:* - - META-INF/*.MF - - - - - - dev.dejvokep.boostedyaml - org.reprogle.dimensionpause.libs - - - org.bstats - org.reprogle.dimensionpause.libs - - - - - - package - - shade - - - - - - - - src/main/resources - true - - - - - - - papermc-repo - https://repo.papermc.io/repository/maven-public/ - - - sonatype - https://oss.sonatype.org/content/groups/public/ - - - - - - io.papermc.paper - paper-api - 1.17.1-R0.1-SNAPSHOT - provided - - - net.kyori - adventure-text-minimessage - 4.17.0 - - - dev.dejvokep - boosted-yaml - 1.3 - - - org.bstats - bstats-bukkit - 3.0.2 - compile - - - diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..57dd2cb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +rootProject.name = "DimensionPause" diff --git a/src/main/java/org/reprogle/dimensionpause/DPMetrics.java b/src/main/java/org/reprogle/dimensionpause/DPMetrics.java new file mode 100644 index 0000000..f8f4f94 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/DPMetrics.java @@ -0,0 +1,25 @@ +package org.reprogle.dimensionpause; + +import org.bstats.bukkit.Metrics; +import org.bstats.charts.AdvancedPie; +import org.bukkit.World; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; + +public class DPMetrics { + DPMetrics(JavaPlugin plugin) { + Metrics metrics = new Metrics(plugin, 19032); + metrics.addCustomChart(new AdvancedPie("dimensions_disabled", () -> { + boolean netherEnabled = DimensionPausePlugin.ds.getState(World.Environment.NETHER); + boolean endEnabled = DimensionPausePlugin.ds.getState(World.Environment.THE_END); + + Map valueMap = new HashMap<>(); + valueMap.put("The End", endEnabled ? 1 : 0); + valueMap.put("Nether", netherEnabled ? 1 : 0); + return valueMap; + })); + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java index ad2a1e4..cc891cc 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java @@ -3,53 +3,64 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bstats.bukkit.Metrics; +import org.bukkit.ChatColor; import org.bukkit.plugin.java.JavaPlugin; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.CommandManager; import org.reprogle.dimensionpause.events.ListenerManager; public final class DimensionPausePlugin extends JavaPlugin { - public static DimensionPausePlugin plugin; - - public static DimensionState ds = null; - - @Override - public void onEnable() { - plugin = this; - ConfigManager.setupConfig(this); - new Metrics(this, 19032); - - CommandManager manager = new CommandManager(); - - getCommand("dimensionpause").setExecutor(manager); - ListenerManager.setupListeners(this); - - ds = new DimensionState(this); - getLogger().info("Dimension Pause has been loaded"); - - new UpdateChecker(this, "https://raw.githubusercontent.com/TerrorByteTW/DimensionPause/master/version.txt").getVersion(latest -> { - if (Integer.parseInt(latest.replace(".", "")) > Integer.parseInt(this.getDescription().getVersion().replace(".", ""))) { - Component updateMessage = Component.text() - .append(CommandFeedback.getChatPrefix()) - .append(Component.text(" ")) - .append(Component.text("There is a new update available: " + latest + ". Please download for the latest features and security updates!", NamedTextColor.RED)) - .build(); - - getServer().getConsoleSender().sendMessage(updateMessage); - } else { - Component noUpdateMessage = Component.text() - .append(CommandFeedback.getChatPrefix()) - .append(Component.text(" ")) - .append(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)) - .build(); - - getServer().getConsoleSender().sendMessage(noUpdateMessage); - } - }); - } - - @Override - public void onDisable() { - getLogger().info("Dimension Pause is shutting down"); - } + public static DimensionPausePlugin plugin; + + public static DimensionState ds = null; + + @Override + public void onEnable() { + plugin = this; + ConfigManager.setupConfig(this); + new DPMetrics(this); + + CommandManager manager = new CommandManager(); + + getCommand("dimensionpause").setExecutor(manager); + ListenerManager.setupListeners(this); + + ds = new DimensionState(this); + getLogger().info("Dimension Pause has been loaded"); + + if (this.getDescription().getVersion().contains("SNAPSHOT")) { + Component updateMessage = Component.text() + .append(CommandFeedback.getChatPrefix()) + .append(Component.text(" ")) + .append(Component.text("You are running a SNAPSHOT version of DimensionPause. Support will not be provided!", NamedTextColor.RED)) + .build(); + + getServer().getConsoleSender().sendMessage(updateMessage); + } else { + new UpdateChecker(this, "https://raw.githubusercontent.com/TerrorByteTW/DimensionPause/master/version.txt").getVersion(latest -> { + if (Integer.parseInt(latest.replace(".", "")) > Integer.parseInt(this.getDescription().getVersion().replace(".", ""))) { + Component updateMessage = Component.text() + .append(CommandFeedback.getChatPrefix()) + .append(Component.text(" ")) + .append(Component.text("There is a new update available: " + latest + ". Please download for the latest features and security updates!", NamedTextColor.RED)) + .build(); + + getServer().getConsoleSender().sendMessage(updateMessage); + } else { + Component noUpdateMessage = Component.text() + .append(CommandFeedback.getChatPrefix()) + .append(Component.text(" ")) + .append(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)) + .build(); + + getServer().getConsoleSender().sendMessage(noUpdateMessage); + } + }); + } + } + + @Override + public void onDisable() { + getLogger().info("Dimension Pause is shutting down"); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java index a021334..760af5a 100644 --- a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java +++ b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java @@ -5,28 +5,47 @@ import org.bukkit.util.Consumer; import java.io.IOException; -import java.io.InputStream; -import java.net.URL; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.Scanner; public record UpdateChecker(Plugin plugin, String link) { + // Reusable HTTP Client so we don't pay performance overhead and don't build new clients every time we need them + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); - /** - * Grabs the version number from the link provided - * - * @param consumer The consumer function - */ - public void getVersion(final Consumer consumer) { - Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> { - try (InputStream inputStream = new URL(this.link).openStream(); - Scanner scanner = new Scanner(inputStream)) { - if (scanner.hasNext()) { - consumer.accept(scanner.next()); - } - } catch (IOException exception) { - plugin.getLogger().info("Unable to check for updates: " + exception.getMessage()); - } - }); - } + /** + * Grabs the version number from the link provided + * + * @param consumer The consumer function + */ + public void getVersion(final Consumer consumer) { + Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(this.link)) + .GET() + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200 && !response.body().isEmpty()) { + try (Scanner scanner = new Scanner(response.body())) { + if (scanner.hasNext()) { + consumer.accept(scanner.next()); + } + } + } else { + plugin.getLogger().info("Unable to check for updates: HTTP " + response.statusCode()); + } + } catch (IOException | InterruptedException e) { + plugin.getLogger().info("Unable to check for updates: " + e.getMessage()); + Thread.currentThread().interrupt(); + } + }); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java index 78b66d0..bf66b41 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java @@ -117,7 +117,7 @@ public static Title getTitleForDimension(World.Environment env) { final Component mainTitle = Component.text().append(mm.deserialize(ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.title"))).build(); final Component subtitle = Component.text().append(mm.deserialize(ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.subtitle"))).build(); - final Title.Times times = Title.Times.of(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); + final Title.Times times = Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); return Title.title(mainTitle, subtitle, times); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 29a53d4..501768b 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: DimensionPause -version: '${project.version}' +version: '@version@' main: org.reprogle.dimensionpause.DimensionPausePlugin api-version: '1.17' prefix: "DimensionPause" From d7429ed344b72ad4306bebc6a2c3a4df6a9ec824 Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sun, 19 Oct 2025 23:00:34 -0500 Subject: [PATCH 3/8] Restrict from running if not on the TerrorByteTw/DimensionPause repo. --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c13592..67206e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,7 @@ on: jobs: build: runs-on: ubuntu-latest + if: github.repository_owner == 'TerrorByteTW' outputs: jar: ${{ steps.artifact-upload.outputs.artifact-id }} steps: From feef2c2e40128943eb080d4a07d876699ba3a50d Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sun, 19 Oct 2025 23:04:55 -0500 Subject: [PATCH 4/8] Update changelog to reflect github run link --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67206e6..6f72e70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} PROJECT_ID: 4dRudYCe TITLE: "${{ steps.version-meta.outputs.version }}" - CHANGELOG: "Generated automatically from commit ${{ github.sha }}" + CHANGELOG: "Automated snapshot built from ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. DO NOT USE IN PRODUCTION!" TYPE: "alpha" run: | chmod +x ./.github/workflows/publish-to-modrinth.sh @@ -71,7 +71,7 @@ jobs: HANGAR_TOKEN: ${{ secrets.HANGAR_TOKEN }} PROJECT_SLUG: 3265 TITLE: "${{ steps.version-meta.outputs.version }}" - DESCRIPTION: "Generated automatically from commit ${{ github.sha }}" + DESCRIPTION: "Automated snapshot built from ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. DO NOT USE IN PRODUCTION!" CHANNEL: "Snapshot" run: | chmod +x ./.github/workflows/publish-to-hangar.sh From b4148c6f54e5b946f1a2e7ae03e519c981318914 Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sat, 17 Jan 2026 18:09:57 -0600 Subject: [PATCH 5/8] - Multiworld support - Storage is now DB backed instead of config backed - This version DOES NOT WORK, there is a bug where the player cannot teleport even if the dimension is unpaused. I'm working on fixing this --- .github/workflows/publish-to-modrinth.sh | 2 +- build.gradle.kts | 11 +- gradle/libs.versions.toml | 24 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../dimensionpause/ConfigManager.java | 8 +- .../reprogle/dimensionpause/DPMetrics.java | 24 +- .../dimensionpause/DimensionPauseModule.java | 42 +++ .../dimensionpause/DimensionPausePlugin.java | 51 +++- .../dimensionpause/DimensionState.java | 241 ++++++++------- .../dimensionpause/UpdateChecker.java | 2 +- .../commands/CommandFeedback.java | 275 ++++++++++-------- .../commands/CommandManager.java | 26 +- .../commands/subcommands/Reload.java | 16 +- .../commands/subcommands/State.java | 90 +++--- .../commands/subcommands/Toggle.java | 97 +++--- .../EntityPortalEnterEventListener.java | 49 ++-- .../events/ListenerManager.java | 52 +++- .../events/PlayerInteractEventListener.java | 59 ++-- .../events/PlayerJoinEventListener.java | 12 +- .../PlayerSpawnLocationEventListener.java | 47 ++- .../events/PlayerTeleportEventListener.java | 19 +- .../events/PortalCreateEventListener.java | 78 ++--- .../dimensionpause/store/Database.java | 103 +++++++ .../reprogle/dimensionpause/store/SQLite.java | 143 +++++++++ .../store/patches/SQLitePatch.java | 24 ++ src/main/resources/lang/en_US.yml | 7 +- src/main/resources/plugin.yml | 4 + 27 files changed, 994 insertions(+), 514 deletions(-) create mode 100644 src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java create mode 100644 src/main/java/org/reprogle/dimensionpause/store/Database.java create mode 100644 src/main/java/org/reprogle/dimensionpause/store/SQLite.java create mode 100644 src/main/java/org/reprogle/dimensionpause/store/patches/SQLitePatch.java diff --git a/.github/workflows/publish-to-modrinth.sh b/.github/workflows/publish-to-modrinth.sh index d6cd415..3a80251 100644 --- a/.github/workflows/publish-to-modrinth.sh +++ b/.github/workflows/publish-to-modrinth.sh @@ -52,7 +52,7 @@ cat > metadata.json < { @@ -72,7 +74,6 @@ tasks.shadowJar { mergeServiceFiles() exclude("META-INF/*.MF") - relocate("dev.dejvokep.boostedyaml", "org.reprogle.dimensionpause.libs") relocate("org.bstats", "org.reprogle.dimensionpause.libs") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78f1a75..29ace73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,13 +2,21 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] -dev-dejvokep-boosted-yaml = "1.3" -io-papermc-paper-paper-api = "1.17.1-R0.1-SNAPSHOT" -net-kyori-adventure-text-minimessage = "4.17.0" -org-bstats-bstats-bukkit = "3.0.2" +boosted-yaml = "1.3.7" +paper-api = "1.21.11-R0.1-SNAPSHOT" +bstats = "3.1.0" +guice = '7.0.0' +lombok = "8.10.2" +folia-api = "1.21.11-R0.1-SNAPSHOT" + + +[plugins] +lombok = { id = "io.freefair.lombok", version.ref = "lombok" } [libraries] -dev-dejvokep-boosted-yaml = { module = "dev.dejvokep:boosted-yaml", version.ref = "dev-dejvokep-boosted-yaml" } -io-papermc-paper-paper-api = { module = "io.papermc.paper:paper-api", version.ref = "io-papermc-paper-paper-api" } -net-kyori-adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "net-kyori-adventure-text-minimessage" } -org-bstats-bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "org-bstats-bstats-bukkit" } +boosted-yaml = { module = "dev.dejvokep:boosted-yaml", version.ref = "boosted-yaml" } +paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper-api" } +bstats = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" } +guice = { module = "com.google.inject:guice", version.ref = "guice" } +folia-api = { module = "dev.folia:folia-api", version.ref = "folia-api" } + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..19a6bde 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/org/reprogle/dimensionpause/ConfigManager.java b/src/main/java/org/reprogle/dimensionpause/ConfigManager.java index 18e4e59..c88954b 100644 --- a/src/main/java/org/reprogle/dimensionpause/ConfigManager.java +++ b/src/main/java/org/reprogle/dimensionpause/ConfigManager.java @@ -1,5 +1,6 @@ package org.reprogle.dimensionpause; +import com.google.inject.Singleton; import dev.dejvokep.boostedyaml.YamlDocument; import dev.dejvokep.boostedyaml.dvs.versioning.BasicVersioning; import dev.dejvokep.boostedyaml.settings.dumper.DumperSettings; @@ -12,6 +13,7 @@ import java.io.IOException; import java.util.List; +@Singleton public class ConfigManager { private static YamlDocument config; private static YamlDocument languageFile; @@ -26,7 +28,7 @@ public class ConfigManager { * * @param plugin The DimensionPause Plugin object */ - public static void setupConfig(Plugin plugin) { + public void setupConfig(Plugin plugin) { plugin.getLogger().info("Attempting to load config files..."); try { config = YamlDocument.create(new File(plugin.getDataFolder(), "config.yml"), @@ -79,7 +81,7 @@ public static void setupConfig(Plugin plugin) { * * @return The YamlDocument object */ - public static YamlDocument getPluginConfig() { + public YamlDocument getPluginConfig() { return config; } @@ -88,7 +90,7 @@ public static YamlDocument getPluginConfig() { * * @return The YamlDocument object */ - public static YamlDocument getLanguageFile() { + public YamlDocument getLanguageFile() { return languageFile; } } diff --git a/src/main/java/org/reprogle/dimensionpause/DPMetrics.java b/src/main/java/org/reprogle/dimensionpause/DPMetrics.java index f8f4f94..cc170dc 100644 --- a/src/main/java/org/reprogle/dimensionpause/DPMetrics.java +++ b/src/main/java/org/reprogle/dimensionpause/DPMetrics.java @@ -1,25 +1,19 @@ package org.reprogle.dimensionpause; import org.bstats.bukkit.Metrics; -import org.bstats.charts.AdvancedPie; -import org.bukkit.World; import org.bukkit.plugin.java.JavaPlugin; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Callable; - public class DPMetrics { DPMetrics(JavaPlugin plugin) { Metrics metrics = new Metrics(plugin, 19032); - metrics.addCustomChart(new AdvancedPie("dimensions_disabled", () -> { - boolean netherEnabled = DimensionPausePlugin.ds.getState(World.Environment.NETHER); - boolean endEnabled = DimensionPausePlugin.ds.getState(World.Environment.THE_END); - - Map valueMap = new HashMap<>(); - valueMap.put("The End", endEnabled ? 1 : 0); - valueMap.put("Nether", netherEnabled ? 1 : 0); - return valueMap; - })); +// metrics.addCustomChart(new AdvancedPie("dimensions_disabled", () -> { +// boolean netherEnabled = DimensionPausePlugin.ds.getState(World.Environment.NETHER); +// boolean endEnabled = DimensionPausePlugin.ds.getState(World.Environment.THE_END); +// +// Map valueMap = new HashMap<>(); +// valueMap.put("The End", endEnabled ? 1 : 0); +// valueMap.put("Nether", netherEnabled ? 1 : 0); +// return valueMap; +// })); } } diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java new file mode 100644 index 0000000..80d8628 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java @@ -0,0 +1,42 @@ +package org.reprogle.dimensionpause; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.multibindings.Multibinder; +import org.reprogle.dimensionpause.commands.CommandFeedback; +import org.reprogle.dimensionpause.commands.SubCommand; +import org.reprogle.dimensionpause.commands.subcommands.Reload; +import org.reprogle.dimensionpause.commands.subcommands.State; +import org.reprogle.dimensionpause.commands.subcommands.Toggle; + +public class DimensionPauseModule extends AbstractModule { + private final DimensionPausePlugin plugin; + private final ConfigManager configManager; + private final CommandFeedback commandFeedback; + + public DimensionPauseModule(DimensionPausePlugin plugin, ConfigManager configManager) { + this.plugin = plugin; + this.configManager = configManager; + configManager.setupConfig(plugin); + + this.commandFeedback = new CommandFeedback(); + } + + @Override + protected void configure() { + // The lifeline of the entire DI system is the plugin object itself + bind(DimensionPausePlugin.class).toInstance(plugin); + bind(ConfigManager.class).toInstance(configManager); + bind(CommandFeedback.class).toInstance(commandFeedback); + + Multibinder subcommandBinder = Multibinder.newSetBinder(binder(), SubCommand.class); + subcommandBinder.addBinding().to(Reload.class); + subcommandBinder.addBinding().to(State.class); + subcommandBinder.addBinding().to(Toggle.class); + } + + public Injector createInjector() { + return Guice.createInjector(this); + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java index cc891cc..fe4e10e 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java @@ -1,36 +1,46 @@ package org.reprogle.dimensionpause; +import com.google.inject.Inject; +import com.google.inject.Injector; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bstats.bukkit.Metrics; -import org.bukkit.ChatColor; +import org.bukkit.Bukkit; import org.bukkit.plugin.java.JavaPlugin; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.CommandManager; import org.reprogle.dimensionpause.events.ListenerManager; public final class DimensionPausePlugin extends JavaPlugin { - public static DimensionPausePlugin plugin; - public static DimensionState ds = null; + @Inject + private CommandFeedback commandFeedback; + @Inject + private ListenerManager listenerManager; + @Inject + private CommandManager commandManager; + + private Injector injector; + + @Override + public void onLoad() { + ConfigManager configManager = new ConfigManager(); + DimensionPauseModule module = new DimensionPauseModule(this, configManager); + injector = module.createInjector(); + injector.injectMembers(this); + } @Override public void onEnable() { - plugin = this; - ConfigManager.setupConfig(this); new DPMetrics(this); - CommandManager manager = new CommandManager(); - - getCommand("dimensionpause").setExecutor(manager); - ListenerManager.setupListeners(this); + getCommand("dimensionpause").setExecutor(this.commandManager); + listenerManager.setupListeners(); - ds = new DimensionState(this); getLogger().info("Dimension Pause has been loaded"); if (this.getDescription().getVersion().contains("SNAPSHOT")) { Component updateMessage = Component.text() - .append(CommandFeedback.getChatPrefix()) + .append(commandFeedback.getChatPrefix()) .append(Component.text(" ")) .append(Component.text("You are running a SNAPSHOT version of DimensionPause. Support will not be provided!", NamedTextColor.RED)) .build(); @@ -40,7 +50,7 @@ public void onEnable() { new UpdateChecker(this, "https://raw.githubusercontent.com/TerrorByteTW/DimensionPause/master/version.txt").getVersion(latest -> { if (Integer.parseInt(latest.replace(".", "")) > Integer.parseInt(this.getDescription().getVersion().replace(".", ""))) { Component updateMessage = Component.text() - .append(CommandFeedback.getChatPrefix()) + .append(commandFeedback.getChatPrefix()) .append(Component.text(" ")) .append(Component.text("There is a new update available: " + latest + ". Please download for the latest features and security updates!", NamedTextColor.RED)) .build(); @@ -48,7 +58,7 @@ public void onEnable() { getServer().getConsoleSender().sendMessage(updateMessage); } else { Component noUpdateMessage = Component.text() - .append(CommandFeedback.getChatPrefix()) + .append(commandFeedback.getChatPrefix()) .append(Component.text(" ")) .append(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)) .build(); @@ -57,10 +67,23 @@ public void onEnable() { } }); } + + if (isFolia()) { + getServer().getConsoleSender().sendMessage( + Component.text("Welcome to Folia!!!! It is assumed you know what you're doing, since Folia is not yet standard. While DimensionPause can run on Folia, it is not yet officially endorsed by the developer, and is also not actively tested. Be wary when using it for now, and report any bugs in Honeypot caused by Folia to the developer!")); + } } @Override public void onDisable() { getLogger().info("Dimension Pause is shutting down"); } + + public Injector getInjector() { + return injector; + } + + private boolean isFolia() { + return Bukkit.getServer().getName().startsWith("Folia"); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionState.java b/src/main/java/org/reprogle/dimensionpause/DimensionState.java index f512a41..68ea038 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionState.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionState.java @@ -1,13 +1,15 @@ package org.reprogle.dimensionpause; +import com.google.inject.Inject; +import com.google.inject.Singleton; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Nullable; import org.reprogle.dimensionpause.commands.CommandFeedback; -import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Collection; import java.util.HashSet; import java.util.Set; @@ -15,132 +17,117 @@ import java.util.logging.Level; import org.bukkit.Location; +import org.reprogle.dimensionpause.store.Database; +import org.reprogle.dimensionpause.store.SQLite; +@Singleton public class DimensionState { - public static final Set alertPlayers = new HashSet<>(); - - // Suppress ConstantValue warning for netherPause and endPaused, because that's not true due to #toggleDimension - public DimensionState(Plugin plugin) { - boolean netherState = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.paused"); - boolean endState = ConfigManager.getPluginConfig().getBoolean("dimensions.end.paused"); - - plugin.getLogger().info("The Nether is currently " + (netherState ? "paused" : "active") + " and the End is currently " + (endState ? "paused" : "active") + "."); - plugin.getLogger().info("You may change is at any time by running /dimensionpause toggle [end | nether] in-game\n"); - plugin.getLogger().info("Disabling any dimension will teleport out players currently in that dimension. See config for more info"); - } - - public void toggleDimension(World.Environment dimension) { - Collection players = DimensionPausePlugin.plugin.getServer().getOnlinePlayers(); - - boolean currentNetherState = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.paused"); - boolean currentEndState = ConfigManager.getPluginConfig().getBoolean("dimensions.end.paused"); - - // This method requires a Dimension enum, and since there's only two, if it's not one then it's the other - if (dimension.equals(World.Environment.NETHER)) { - currentNetherState = !currentNetherState; - try { - ConfigManager.getPluginConfig().set("dimensions.nether.paused", currentNetherState); - ConfigManager.getPluginConfig().save(); - } catch (IOException e) { - DimensionPausePlugin.plugin.getLogger().warning(CommandFeedback.sendCommandFeedback("io-exception").toString()); - } - - alertOfStateChange(players, dimension, currentNetherState); - } else { - currentEndState = !currentEndState; - try { - ConfigManager.getPluginConfig().set("dimensions.end.paused", currentEndState); - ConfigManager.getPluginConfig().save(); - } catch (IOException e) { - DimensionPausePlugin.plugin.getLogger().warning(CommandFeedback.sendCommandFeedback("io-exception").toString()); - } - alertOfStateChange(players, dimension, currentEndState); - } - - if (currentNetherState) { - boolean bypassable = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - for (Player player : DimensionPausePlugin.plugin.getServer().getOnlinePlayers()) { - if (player.getWorld().getEnvironment().equals(World.Environment.NETHER) && !canBypass(player, bypassable)) { - kickToWorld(player, dimension, true); - } - } - } - - if (currentEndState) { - boolean bypassable = ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); - for (Player player : DimensionPausePlugin.plugin.getServer().getOnlinePlayers()) { - if (player.getWorld().getEnvironment().equals(World.Environment.THE_END) && !canBypass(player, bypassable)) { - kickToWorld(player, dimension, true); - } - } - } - } - - @Nullable - public Location kickToWorld(Player player, World.Environment dimension, boolean teleport) { - Location bedSpawn = player.getBedSpawnLocation(); - Location loc; - - if (ConfigManager.getPluginConfig().getBoolean("try-bed-first") && bedSpawn != null) { - if (teleport) player.teleport(bedSpawn); - loc = player.getBedSpawnLocation(); - } else { - World world = Bukkit.getWorld(ConfigManager.getPluginConfig().getString("kick-world")); - if (world == null) { - DimensionPausePlugin.plugin.getLogger().log(Level.WARNING, "IMPORTANT MESSAGE! A world has been paused, but at least one player is still in it ( {0}). This player doesn''t have a bed, and the kick-world configured in config was not obtainable, so we cannot teleport players out of the world. Please intervene!", player.getName()); - return null; - } - - player.teleport(world.getSpawnLocation()); - loc = world.getSpawnLocation(); - } - - if (teleport) { - alertPlayer(player, dimension); - } - - return loc; - } - - public boolean getState(World.Environment dimension) { - return switch (dimension) { - case NETHER -> ConfigManager.getPluginConfig().getBoolean("dimensions.nether.paused"); - case THE_END -> ConfigManager.getPluginConfig().getBoolean("dimensions.end.paused"); - default -> false; - }; - } - - public boolean canBypass(Player player, boolean bypassableFlag) { - if (player.isOp()) return true; - if (!bypassableFlag) return false; - return player.hasPermission("dimensionpause.bypass"); - } - - public void alertPlayer(Player player, World.Environment dimension) { - String env = dimension.equals(World.Environment.NETHER) ? "nether" : "end"; - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.chat.enabled"); - - if (sendTitle) { - player.showTitle(CommandFeedback.getTitleForDimension(dimension)); - } - - if (sendChat) { - player.sendMessage(CommandFeedback.getChatForDimension(dimension)); - } - } - - private void alertOfStateChange(Collection players, World.Environment environment, boolean newState) { - // Get a string value for the dimension. This is useful later on. - String env = environment.equals(World.Environment.NETHER) ? "nether" : "end"; - - if (!ConfigManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.on-toggle.enabled")) return; - - for (Player player : players) { - player.sendMessage(CommandFeedback.getToggleMessageForDimension(environment, newState)); - } - - } - + public final Set alertPlayers = new HashSet<>(); + @Inject + private ConfigManager configManager; + @Inject + private DimensionPausePlugin plugin; + @Inject + private CommandFeedback commandFeedback; + @Inject + private SQLite db; + + public void toggleDimension(World world, World.Environment dimension, @Nullable LocalDate expirationTime) { + toggleDimension(world.getName(), dimension, expirationTime); + } + + public void toggleDimension(String world, World.Environment dimension, @Nullable LocalDate expirationTime) { + Collection players = plugin.getServer().getOnlinePlayers(); + + boolean worldDimensionEnabled = db.isWorldEnabled(world, dimension).enabled(); + db.setWorld(world, dimension, !worldDimensionEnabled, expirationTime); + + alertOfStateChange(players, world, dimension, !worldDimensionEnabled); + + if (!worldDimensionEnabled) { + boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions." + (dimension.equals(World.Environment.NETHER) ? "nether" : "end") + ".bypassable"); + for (Player player : plugin.getServer().getOnlinePlayers()) { + if (player.getWorld().getEnvironment().equals(dimension) && !canBypass(player, bypassable)) { + kickToWorld(player, dimension, true); + } + } + } + } + + /** + * A helper method to kick a player to a world, OR to get the location of the place they'd be spawned at (In the case of Async events) + * + * @param player The Player being kicked + * @param dimension The dimension the player was kicked FROM + * @param teleport Whether to teleport the player once the respawn location is confirmed + * @return The Location the player was/will be teleported to + */ + @Nullable + public Location kickToWorld(Player player, World.Environment dimension, boolean teleport) { + Location loc = player.getRespawnLocation(); + + if (configManager.getPluginConfig().getBoolean("try-bed-first") && loc != null) { + if (teleport) player.teleportAsync(loc); + } else { + World world = Bukkit.getWorld(configManager.getPluginConfig().getString("kick-world")); + + // We can't teleport the player if the kick-world is invalid, so we must return null + if (world == null) { + plugin.getLogger().log(Level.WARNING, "IMPORTANT MESSAGE! A world has been paused, but at least one player is still in it ({0}). This player doesn't have a valid respawn location, and the kick-world configured in config was not obtainable, so we cannot teleport players out of the world. Please intervene!", player.getName()); + return null; + } + + // Teleport the player asynchronously (Folia) to the kick-world's spawn + if (teleport) player.teleportAsync(world.getSpawnLocation()); + loc = world.getSpawnLocation(); + } + + // If we teleported the player, alert them + if (teleport) { + alertPlayer(player, dimension); + } + + return loc; + } + + public Database.WorldPauseStatus getState(World world, World.Environment dimension) { + return getState(world.getName(), dimension); + } + + public Database.WorldPauseStatus getState(String world, World.Environment dimension) { + return db.isWorldEnabled(world, dimension); + } + + public boolean canBypass(Player player, boolean bypassableFlag) { + if (player.isOp()) return true; + if (!bypassableFlag) return false; + return player.hasPermission("dimensionpause.bypass"); + } + + public void alertPlayer(Player player, World.Environment dimension) { + String env = dimension.equals(World.Environment.NETHER) ? "nether" : "end"; + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.title.enabled"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.chat.enabled"); + + if (sendTitle) { + player.showTitle(commandFeedback.getTitleForDimension(dimension)); + } + + if (sendChat) { + player.sendMessage(commandFeedback.getChatForDimension(dimension)); + } + } + + private void alertOfStateChange(Collection players, String world, World.Environment environment, boolean newState) { + // Get a string value for the dimension. This is useful later on. + String env = environment.equals(World.Environment.NETHER) ? "nether" : "end"; + + if (!configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.on-toggle.enabled")) return; + + for (Player player : players) { + player.sendMessage(commandFeedback.getToggleMessageForDimension(world, environment, newState)); + } + + } } diff --git a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java index 760af5a..9091ee5 100644 --- a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java +++ b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java @@ -2,7 +2,6 @@ import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; -import org.bukkit.util.Consumer; import java.io.IOException; import java.net.URI; @@ -10,6 +9,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Scanner; +import java.util.function.Consumer; public record UpdateChecker(Plugin plugin, String link) { // Reusable HTTP Client so we don't pay performance overhead and don't build new clients every time we need them diff --git a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java index bf66b41..ec1e745 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java @@ -1,143 +1,164 @@ package org.reprogle.dimensionpause.commands; +import com.google.inject.Inject; import dev.dejvokep.boostedyaml.YamlDocument; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.title.Title; import org.bukkit.World; import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.store.Database; +import javax.annotation.Nullable; import java.time.Duration; +import java.time.format.DateTimeFormatter; import java.util.Objects; public class CommandFeedback { - public static final MiniMessage mm = MiniMessage.miniMessage(); - - /** - * Return the chat prefix object from config - * - * @return The chat prefix, preformatted with color and other modifiers - */ - public static Component getChatPrefix() { - return mm.deserialize(Objects.requireNonNull(ConfigManager.getLanguageFile().getString("prefix"))); - } - - /** - * A helper class which helps to reduce boilerplate player.sendMessage code by providing the strings to send instead - * of having to copy and paste them. - * - * @param feedback The string to send back - * @return The Feedback string - */ - public static Component sendCommandFeedback(String feedback, String... dimension) { - Component feedbackMessage; - Component chatPrefix = getChatPrefix(); - YamlDocument languageFile = ConfigManager.getLanguageFile(); - - switch (feedback.toLowerCase()) { - case "usage" -> - feedbackMessage = Component.text().content("\n \n \n \n \n \n-----------------------\n \n").color(NamedTextColor.WHITE) - .append(chatPrefix).append(Component.text(" ")) - .append(Component.text("Need help?\n \n", NamedTextColor.WHITE)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("toggle [end | nether] \n", NamedTextColor.GRAY)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("state [end | nether] \n", NamedTextColor.GRAY)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("reload \n \n", NamedTextColor.GRAY)) - .append(Component.text("-----------------------", NamedTextColor.WHITE)) - .build(); - case "nopermission" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("no-permission"))) - .build(); - case "reload" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("reload"))) - .build(); - case "io-exception" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("io-exception"))) - .build(); - case "newstate" -> { - Component pausedComponent = Component.text("paused").color(NamedTextColor.RED); - Component unpausedComponent = Component.text("unpaused").color(NamedTextColor.GREEN); - - if (dimension.length > 0 && dimension[0].equals("nether")) { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("toggled.nether"))) - .append(DimensionPausePlugin.ds.getState(World.Environment.NETHER) ? pausedComponent : unpausedComponent) - .build(); - } else if (dimension.length > 0 && dimension[0].equals("end")) { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("toggled.end"))) - .append(DimensionPausePlugin.ds.getState(World.Environment.THE_END) ? pausedComponent : unpausedComponent) - .build(); - } else { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("toggled.default"))) - .build(); - } - } - case "state" -> { - Component pausedComponent = Component.text("paused").color(NamedTextColor.RED); - Component unpausedComponent = Component.text("unpaused").color(NamedTextColor.GREEN); - - if (dimension.length > 0 && dimension[0].equals("nether")) { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("state.nether"))) - .append(DimensionPausePlugin.ds.getState(World.Environment.NETHER) ? pausedComponent : unpausedComponent) - .build(); - } else if (dimension.length > 0 && dimension[0].equals("end")) { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("state.end"))) - .append(DimensionPausePlugin.ds.getState(World.Environment.THE_END) ? pausedComponent : unpausedComponent) - .build(); - } else { - return Component.empty(); - } - } - default -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("unknown-error"))) - .build(); - } - - return feedbackMessage; - } - - public static Title getTitleForDimension(World.Environment env) { - String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - - final Component mainTitle = Component.text().append(mm.deserialize(ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.title"))).build(); - final Component subtitle = Component.text().append(mm.deserialize(ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.subtitle"))).build(); - - final Title.Times times = Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); - return Title.title(mainTitle, subtitle, times); - } - - public static Component getChatForDimension(World.Environment env) { - String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - return Component.text() - .append(getChatPrefix()) - .append(mm.deserialize(ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.chat.message"))) - .build(); - } - - public static Component getToggleMessageForDimension(World.Environment env, boolean newState) { - String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - String stateParsed = newState ? "paused" : "unpaused"; - - String preparsedText = ConfigManager.getPluginConfig().getString("dimensions." + environment + ".alert.on-toggle.message").replace("%state%", stateParsed); - return Component.text() - .append(getChatPrefix()) - .append(Component.text(" ")) - .append(mm.deserialize(preparsedText)) - .build(); - } + public static final MiniMessage mm = MiniMessage.miniMessage(); + + @Inject + private ConfigManager configManager; + @Inject + private DimensionState state; + + /** + * Return the chat prefix object from config + * + * @return The chat prefix, preformatted with color and other modifiers + */ + public Component getChatPrefix() { + return mm.deserialize(Objects.requireNonNull(configManager.getLanguageFile().getString("prefix"))); + } + + /** + * A helper class which helps to reduce boilerplate player.sendMessage code by providing the strings to send instead + * of having to copy and paste them. + * + * @param feedback The string to send back + * @return The Feedback string + */ + public Component sendCommandFeedback(String feedback, @Nullable String world, @Nullable String dimension) { + Component feedbackMessage; + Component chatPrefix = getChatPrefix(); + YamlDocument languageFile = configManager.getLanguageFile(); + World.Environment environment = null; + if (dimension != null && (dimension.equalsIgnoreCase("end") || dimension.equalsIgnoreCase("nether"))) + environment = (dimension.equalsIgnoreCase("nether") ? World.Environment.NETHER : World.Environment.THE_END); + + switch (feedback.toLowerCase()) { + case "usage" -> + feedbackMessage = Component.text().content("\n \n \n \n \n \n-----------------------\n \n").color(NamedTextColor.WHITE) + .append(chatPrefix).append(Component.text(" ")) + .append(Component.text("Need help?\n \n", NamedTextColor.WHITE)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("toggle [end | nether] \n", NamedTextColor.GRAY)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("state [end | nether] \n", NamedTextColor.GRAY)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("reload \n \n", NamedTextColor.GRAY)) + .append(Component.text("-----------------------", NamedTextColor.WHITE)) + .build(); + case "nopermission" -> feedbackMessage = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("no-permission"))) + .build(); + case "reload" -> feedbackMessage = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("reload"))) + .build(); + case "io-exception" -> feedbackMessage = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("io-exception"))) + .build(); + case "newstate" -> { + Component pausedComponent = mm.deserialize(languageFile.getString("state.paused")); + Component unpausedComponent = mm.deserialize(languageFile.getString("state.unpaused")); + if (environment == null) { + feedbackMessage = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("toggled.default"))) + .build(); + } else { + Database.WorldPauseStatus worldState = state.getState(world, environment); + + TextComponent.Builder builder = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("toggled." + dimension))) + .append(!worldState.enabled() ? pausedComponent : unpausedComponent); + + if (worldState.expiresAt() != null) { + Component untilComponent = mm.deserialize(languageFile.getString("state.until")); + builder.append(untilComponent) + .append(Component.text(worldState.expiresAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))) + .append(Component.text(" UTC")); + } + + feedbackMessage = builder.build(); + } + } + case "state" -> { + Component pausedComponent = mm.deserialize(languageFile.getString("state.paused")); + Component unpausedComponent = mm.deserialize(languageFile.getString("state.unpaused")); + if (environment == null) return Component.empty(); + + Database.WorldPauseStatus worldState = state.getState(world, environment); + TextComponent.Builder builder = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("state." + dimension))) + .append(!worldState.enabled() ? pausedComponent : unpausedComponent); + + if (worldState.expiresAt() != null) { + Component untilComponent = mm.deserialize(languageFile.getString("state.until")); + builder.append(untilComponent) + .append(Component.text(worldState.expiresAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))) + .append(Component.text(" UTC")); + } + + feedbackMessage = builder.build(); + } + default -> feedbackMessage = Component.text().append(chatPrefix) + .append(Component.text(" ")) + .append(mm.deserialize(languageFile.getString("unknown-error"))) + .build(); + } + + return feedbackMessage; + } + + public Title getTitleForDimension(World.Environment env) { + String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; + + final Component mainTitle = Component.text().append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.title"))).build(); + final Component subtitle = Component.text().append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.subtitle"))).build(); + + final Title.Times times = Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); + return Title.title(mainTitle, subtitle, times); + } + + public Component getChatForDimension(World.Environment env) { + String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; + return Component.text() + .append(getChatPrefix()) + .append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.chat.message"))) + .build(); + } + + public Component getToggleMessageForDimension(World world, World.Environment env, boolean newState) { + return getToggleMessageForDimension(world.getName(), env, newState); + } + + public Component getToggleMessageForDimension(String world, World.Environment env, boolean newState) { + String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; + String stateParsed = newState ? "paused" : "unpaused"; + String worldFmtd = "" + world + ""; + + String preparsedText = configManager.getPluginConfig().getString("dimensions." + environment + ".alert.on-toggle.message").replace("%world%", worldFmtd).replace("%state%", stateParsed); + return Component.text() + .append(getChatPrefix()) + .append(Component.text(" ")) + .append(mm.deserialize(preparsedText)) + .build(); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/commands/CommandManager.java b/src/main/java/org/reprogle/dimensionpause/commands/CommandManager.java index 727bf05..db70da1 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/CommandManager.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/CommandManager.java @@ -1,5 +1,7 @@ package org.reprogle.dimensionpause.commands; +import com.google.inject.Inject; +import com.google.inject.Singleton; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.TabExecutor; @@ -9,25 +11,31 @@ import org.reprogle.dimensionpause.commands.subcommands.Reload; import org.reprogle.dimensionpause.commands.subcommands.State; import org.reprogle.dimensionpause.commands.subcommands.Toggle; +import lombok.Getter; import java.util.ArrayList; import java.util.List; +import java.util.Set; +@Singleton public class CommandManager implements TabExecutor { - private final ArrayList subcommands = new ArrayList<>(); + private final CommandFeedback commandFeedback; - public CommandManager() { - subcommands.add(new Toggle()); - subcommands.add(new Reload()); - subcommands.add(new State()); + @Getter + @Inject + private Set subcommands; + + @Inject + public CommandManager(CommandFeedback commandFeedback) { + this.commandFeedback = commandFeedback; } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { if (!sender.hasPermission("dimensionpause.commands")) { - sender.sendMessage(CommandFeedback.sendCommandFeedback("nopermission")); + sender.sendMessage(commandFeedback.sendCommandFeedback("nopermission", null, null)); return false; } @@ -38,7 +46,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command for (SubCommand subcommand : subcommands) { if (args[0].equalsIgnoreCase(subcommand.getName())) { if (!checkPermissions(sender, subcommand)) { - sender.sendMessage(CommandFeedback.sendCommandFeedback("nopermission")); + sender.sendMessage(commandFeedback.sendCommandFeedback("nopermission", null, null)); return false; } @@ -47,9 +55,9 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command } } - sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); } else { - sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); } return false; diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java index 11169b1..7620b42 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java @@ -1,5 +1,6 @@ package org.reprogle.dimensionpause.commands.subcommands; +import com.google.inject.Inject; import org.bukkit.command.CommandSender; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.commands.CommandFeedback; @@ -10,6 +11,11 @@ import java.util.List; public class Reload implements SubCommand { + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + @Override public String getName() { return "reload"; @@ -18,13 +24,13 @@ public String getName() { @Override public void perform(CommandSender sender, String[] args) { try { - ConfigManager.getPluginConfig().reload(); - ConfigManager.getPluginConfig().save(); + configManager.getPluginConfig().reload(); + configManager.getPluginConfig().save(); - ConfigManager.getLanguageFile().reload(); - ConfigManager.getLanguageFile().save(); + configManager.getLanguageFile().reload(); + configManager.getLanguageFile().save(); - sender.sendMessage(CommandFeedback.sendCommandFeedback("reload")); + sender.sendMessage(commandFeedback.sendCommandFeedback("reload", null, null)); } catch (IOException e) { // Nothing diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java index 4acd134..afa4caf 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java @@ -1,5 +1,8 @@ package org.reprogle.dimensionpause.commands.subcommands; +import com.google.inject.Inject; +import org.bukkit.Bukkit; +import org.bukkit.World; import org.bukkit.command.CommandSender; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.SubCommand; @@ -8,43 +11,52 @@ import java.util.List; public class State implements SubCommand { - @Override - public String getName() { - return "state"; - } - - @Override - public void perform(CommandSender sender, String[] args) { - if (args.length >= 2) { - switch (args[1].toLowerCase()) { - case "nether", "end" -> sender.sendMessage(CommandFeedback.sendCommandFeedback("state", args[1].toLowerCase())); - default -> sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); - } - } else { - sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); - } - } - - @Override - public List getSubcommands(CommandSender sender, String[] args) { - List subcommands = new ArrayList<>(); - - // We are already in argument 1 of the command, hence why this is a subcommand - // class. Argument 2 is the - // subcommand for the subcommand, - // aka /dimensionpause state - - if (args.length == 2) { - subcommands.add("nether"); - subcommands.add("end"); - } - return subcommands; - } - - @Override - public List getRequiredPermissions() { - List permissions = new ArrayList<>(); - permissions.add("dimensionpause.state"); - return permissions; - } + @Inject + CommandFeedback commandFeedback; + + @Override + public String getName() { + return "state"; + } + + @Override + public void perform(CommandSender sender, String[] args) { + if (args.length >= 3) { + String world = args[1]; + String dimension = args[2].toLowerCase(); + sender.sendMessage(commandFeedback.sendCommandFeedback("state", world, dimension)); + } else { + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); + } + } + + @Override + public List getSubcommands(CommandSender sender, String[] args) { + List subcommands = new ArrayList<>(); + + // We are already in argument 1 of the command, hence why this is a subcommand + // class. Argument 2 is the + // subcommand for the subcommand, + // aka /dimensionpause state + // Same with argument 3 + // aka /dimensionpause state + + if (args.length == 2) { + Bukkit.getWorlds().forEach(world -> { + if (world.getEnvironment().equals(World.Environment.NORMAL)) + subcommands.add(world.getName()); + }); + } else if (args.length == 3) { + subcommands.add("nether"); + subcommands.add("end"); + } + return subcommands; + } + + @Override + public List getRequiredPermissions() { + List permissions = new ArrayList<>(); + permissions.add("dimensionpause.state"); + return permissions; + } } diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java index ac074d3..cb1c78c 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java @@ -1,8 +1,10 @@ package org.reprogle.dimensionpause.commands.subcommands; +import com.google.inject.Inject; +import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.CommandSender; -import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.SubCommand; @@ -10,45 +12,56 @@ import java.util.List; public class Toggle implements SubCommand { - @Override - public String getName() { - return "toggle"; - } - - @Override - public void perform(CommandSender sender, String[] args) { - if (args.length >= 2) { - switch (args[1].toLowerCase()) { - case "nether" -> DimensionPausePlugin.ds.toggleDimension(World.Environment.NETHER); - case "end" -> DimensionPausePlugin.ds.toggleDimension(World.Environment.THE_END); - default -> sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); - } - sender.sendMessage(CommandFeedback.sendCommandFeedback("newstate", args[1].toLowerCase())); - } else { - sender.sendMessage(CommandFeedback.sendCommandFeedback("usage")); - } - } - - @Override - public List getSubcommands(CommandSender sender, String[] args) { - List subcommands = new ArrayList<>(); - - // We are already in argument 1 of the command, hence why this is a subcommand - // class. Argument 2 is the - // subcommand for the subcommand, - // aka /dimensionpause toggle - - if (args.length == 2) { - subcommands.add("nether"); - subcommands.add("end"); - } - return subcommands; - } - - @Override - public List getRequiredPermissions() { - List permissions = new ArrayList<>(); - permissions.add("dimensionpause.toggle"); - return permissions; - } + @Inject + CommandFeedback commandFeedback; + @Inject + DimensionState state; + + @Override + public String getName() { + return "toggle"; + } + + @Override + public void perform(CommandSender sender, String[] args) { + if (args.length >= 3 && (args[2].equalsIgnoreCase("end") || args[2].equalsIgnoreCase("nether"))) { + String world = args[1]; + String dimension = args[2].toLowerCase(); + World.Environment environment = dimension.equalsIgnoreCase("nether") ? World.Environment.NETHER : World.Environment.THE_END; + state.toggleDimension(world, environment, null); + sender.sendMessage(commandFeedback.sendCommandFeedback("newstate", world, dimension)); + } else { + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); + } + } + + @Override + public List getSubcommands(CommandSender sender, String[] args) { + List subcommands = new ArrayList<>(); + + // We are already in argument 1 of the command, hence why this is a subcommand + // class. Argument 2 is the + // subcommand for the subcommand, + // aka /dimensionpause state + // Same with argument 3 + // aka /dimensionpause state + + if (args.length == 2) { + Bukkit.getWorlds().forEach(world -> { + if (world.getEnvironment().equals(World.Environment.NORMAL)) + subcommands.add(world.getName()); + }); + } else if (args.length == 3) { + subcommands.add("nether"); + subcommands.add("end"); + } + return subcommands; + } + + @Override + public List getRequiredPermissions() { + List permissions = new ArrayList<>(); + permissions.add("dimensionpause.toggle"); + return permissions; + } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java index f2f3795..3007df0 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java @@ -1,5 +1,6 @@ package org.reprogle.dimensionpause.events; +import com.google.inject.Inject; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -16,6 +17,7 @@ import org.bukkit.util.Vector; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; import java.util.HashSet; @@ -24,13 +26,22 @@ public class EntityPortalEnterEventListener implements Listener { + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + @Inject + DimensionState state; + @Inject + DimensionPausePlugin plugin; + private final Set playersBeingHandled = new HashSet<>(); // Handler for nether portals @EventHandler(priority = EventPriority.HIGHEST) public void onNetherPortalEnter(EntityPortalEnterEvent event) { // Check if the event is a player, if nether bounce-back option is enabled, and if the nether is currently paused - if (!(event.getEntity() instanceof Player p) || !ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bounce-back") || !DimensionPausePlugin.ds.getState(World.Environment.NETHER)) { + if (!(event.getEntity() instanceof Player p) || !configManager.getPluginConfig().getBoolean("dimensions.nether.bounce-back") || state.getState(p.getWorld(), World.Environment.NETHER).enabled()) { return; } @@ -41,10 +52,12 @@ public void onNetherPortalEnter(EntityPortalEnterEvent event) { } // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.canBypass(p, ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"))) { + if (state.canBypass(p, configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"))) { return; } + event.setCancelled(true); + // Ensure this event is not already being handled if (playersBeingHandled.contains(p.getUniqueId())) { return; @@ -82,21 +95,21 @@ public void onNetherPortalEnter(EntityPortalEnterEvent event) { } // Delay the velocity and removal of the player from the set - DimensionPausePlugin.plugin.getServer().getScheduler().runTaskLater(DimensionPausePlugin.plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.DAMAGE_RESISTANCE, 200, 5, false, false)); + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); p.setVelocity(new Vector(newVecX, .7, newVecZ)); playersBeingHandled.remove(p.getUniqueId()); }, 1L); // 1 tick or 1/20 of a second - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); if (sendTitle) { - p.showTitle(CommandFeedback.getTitleForDimension(World.Environment.NETHER)); + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); } if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(World.Environment.NETHER)); + p.sendMessage(commandFeedback.getChatForDimension(World.Environment.NETHER)); } } @@ -104,7 +117,7 @@ public void onNetherPortalEnter(EntityPortalEnterEvent event) { @EventHandler(priority = EventPriority.HIGHEST) public void onEndPortalEnter(EntityPortalEnterEvent event) { // Check if the event is a player, if the end bounce-back option is enabled, and if the end is currently paused - if (!(event.getEntity() instanceof Player p) || !ConfigManager.getPluginConfig().getBoolean("dimensions.end.bounce-back") || !DimensionPausePlugin.ds.getState(World.Environment.THE_END)) { + if (!(event.getEntity() instanceof Player p) || !configManager.getPluginConfig().getBoolean("dimensions.end.bounce-back") || state.getState(p.getWorld(), World.Environment.THE_END).enabled()) { return; } @@ -115,10 +128,12 @@ public void onEndPortalEnter(EntityPortalEnterEvent event) { } // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.canBypass(p, ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"))) { + if (state.canBypass(p, configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"))) { return; } + event.setCancelled(true); + // Ensure this event is not already being handled if (playersBeingHandled.contains(p.getUniqueId())) { return; @@ -139,20 +154,20 @@ public void onEndPortalEnter(EntityPortalEnterEvent event) { p.setVelocity(knockbackDirection); // Delay the velocity and removal of the player from the set - DimensionPausePlugin.plugin.getServer().getScheduler().runTaskLater(DimensionPausePlugin.plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.DAMAGE_RESISTANCE, 200, 5, false, false)); + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); playersBeingHandled.remove(p.getUniqueId()); }, 5L); // 1 tick or 1/20 of a second - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); if (sendTitle) { - p.showTitle(CommandFeedback.getTitleForDimension(World.Environment.THE_END)); + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.THE_END)); } if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(World.Environment.THE_END)); + p.sendMessage(commandFeedback.getChatForDimension(World.Environment.THE_END)); } } @@ -163,7 +178,7 @@ public void onPlayerDamageEvent(EntityDamageEvent event) { if (!(event.getEntity() instanceof Player p)) return; if (playersBeingHandled.contains(p.getUniqueId())) { - p.addPotionEffect(new PotionEffect(PotionEffectType.DAMAGE_RESISTANCE, 10, 5, false, false)); + p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 10, 5, false, false)); } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java index 27bbaac..9dbe7cf 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java +++ b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java @@ -1,21 +1,45 @@ package org.reprogle.dimensionpause.events; -import org.bukkit.plugin.Plugin; +import com.google.inject.Inject; +import org.bukkit.event.Listener; +import org.bukkit.plugin.PluginManager; +import org.reprogle.dimensionpause.ConfigManager; +import org.reprogle.dimensionpause.DimensionPausePlugin; + +import java.util.ArrayList; +import java.util.List; public class ListenerManager { - /** - * Set's up all the listeners in the entire plugin - * - * @param plugin The Honeypot plugin instance - */ - public static void setupListeners(Plugin plugin) { - plugin.getServer().getPluginManager().registerEvents(new PlayerSpawnLocationEventListener(), plugin); - plugin.getServer().getPluginManager().registerEvents(new PlayerJoinEventListener(), plugin); - plugin.getServer().getPluginManager().registerEvents(new PlayerTeleportEventListener(), plugin); - plugin.getServer().getPluginManager().registerEvents(new PlayerInteractEventListener(), plugin); - plugin.getServer().getPluginManager().registerEvents(new PortalCreateEventListener(), plugin); - plugin.getServer().getPluginManager().registerEvents(new EntityPortalEnterEventListener(), plugin); - } + private final DimensionPausePlugin plugin; + + @Inject + PlayerSpawnLocationEventListener playerSpawnLocationEventListener; + @Inject + PlayerJoinEventListener playerJoinEventListener; + @Inject + PlayerTeleportEventListener playerTeleportEventListener; + @Inject + PlayerInteractEventListener playerInteractEventListener; + @Inject + PortalCreateEventListener portalCreateEventListener; + @Inject + EntityPortalEnterEventListener entityPortalEnterEventListener; + + @Inject + ListenerManager(DimensionPausePlugin plugin) { + this.plugin = plugin; + } + + /** + * Set's up all the listeners in the entire plugin + */ + public void setupListeners() { + final List listeners = new ArrayList<>(List.of(playerSpawnLocationEventListener, + playerJoinEventListener, playerTeleportEventListener, playerInteractEventListener, + portalCreateEventListener, entityPortalEnterEventListener)); + PluginManager pm = plugin.getServer().getPluginManager(); + listeners.forEach(event -> pm.registerEvents(event, plugin)); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java index 2b8e10a..68b6271 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java @@ -1,5 +1,6 @@ package org.reprogle.dimensionpause.events; +import com.google.inject.Inject; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.entity.Player; @@ -9,33 +10,39 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; public class PlayerInteractEventListener implements Listener { - - @EventHandler() - public static void onPlayerInteractEvent(PlayerInteractEvent event) { - if (event.getAction() == Action.RIGHT_CLICK_BLOCK && event.getClickedBlock() != null && event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) { - if(!DimensionPausePlugin.ds.getState(World.Environment.THE_END)) return; - - boolean bypassable = ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); - - if (DimensionPausePlugin.ds.getState(World.Environment.THE_END)) { - if (DimensionPausePlugin.ds.canBypass(event.getPlayer(), bypassable)) return; - event.setCancelled(true); - Player p = event.getPlayer(); - - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(CommandFeedback.getTitleForDimension(World.Environment.THE_END)); - } - - if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(World.Environment.THE_END)); - } - } - } - } + @Inject + DimensionState state; + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + + @EventHandler() + public void onPlayerInteractEvent(PlayerInteractEvent event) { + if (event.getAction() == Action.RIGHT_CLICK_BLOCK && event.getClickedBlock() != null && event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) { + World world = event.getPlayer().getWorld(); + if (state.getState(world, World.Environment.THE_END).enabled()) return; + + boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); + + if (state.canBypass(event.getPlayer(), bypassable)) return; + event.setCancelled(true); + Player p = event.getPlayer(); + + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); + + if (sendTitle) { + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.THE_END)); + } + + if (sendChat) { + p.sendMessage(commandFeedback.getChatForDimension(World.Environment.THE_END)); + } + } + } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java index 953e17a..0d14104 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java @@ -1,28 +1,30 @@ package org.reprogle.dimensionpause.events; +import com.google.inject.Inject; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -import org.reprogle.dimensionpause.DimensionPausePlugin; import org.reprogle.dimensionpause.DimensionState; public class PlayerJoinEventListener implements Listener { + @Inject + DimensionState state; @EventHandler(priority = EventPriority.HIGHEST) - public static void onPlayerJoin(PlayerJoinEvent event) { + public void onPlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - if (!DimensionState.alertPlayers.contains(player.getUniqueId())) { + if (!state.alertPlayers.contains(player.getUniqueId())) { return; } World world = player.getWorld(); - DimensionPausePlugin.ds.alertPlayer(player, world.getEnvironment()); - DimensionState.alertPlayers.remove(player.getUniqueId()); + state.alertPlayer(player, world.getEnvironment()); + state.alertPlayers.remove(player.getUniqueId()); } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java index 3ec075d..ebae2a5 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java @@ -1,21 +1,40 @@ package org.reprogle.dimensionpause.events; +import com.destroystokyo.paper.profile.PlayerProfile; +import com.google.inject.Inject; +import io.papermc.paper.connection.PlayerConfigurationConnection; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent; import org.bukkit.event.Listener; import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; import org.reprogle.dimensionpause.DimensionState; -import org.spigotmc.event.player.PlayerSpawnLocationEvent; -public class PlayerSpawnLocationEventListener implements Listener{ +import java.util.UUID; +public class PlayerSpawnLocationEventListener implements Listener { + + @Inject + private ConfigManager configManager; + + @Inject + private DimensionState state; + + @Inject + private DimensionPausePlugin plugin; + + // AsyncPlayerSpawnLocationEvent is only available in 1.21+ + @SuppressWarnings("UnstableApiUsage") @EventHandler(priority = EventPriority.HIGHEST) - public static void onPlayerSpawn(PlayerSpawnLocationEvent event) { + public void onPlayerSpawn(AsyncPlayerSpawnLocationEvent event) { + if (event.isNewPlayer()) return; World world = event.getSpawnLocation().getWorld(); - String kickWorld = ConfigManager.getPluginConfig().getString("kick-world"); + String kickWorld = configManager.getPluginConfig().getString("kick-world"); // No need to do anything if the player is already in the world they would be kicked to if (world.getName().equals(kickWorld)) { @@ -23,22 +42,28 @@ public static void onPlayerSpawn(PlayerSpawnLocationEvent event) { } // Grab the bypassable values for the nether and end. - boolean netherBypass = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - boolean endBypass = ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); + boolean netherBypass = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); + boolean endBypass = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); // If the environment the player is teleporting to is disabled, do the following - if (DimensionPausePlugin.ds.getState(world.getEnvironment())) { + if (!state.getState(world, world.getEnvironment()).enabled()) { // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.canBypass(event.getPlayer(), world.getEnvironment().equals(World.Environment.NETHER) ? netherBypass : endBypass)) + UUID playerUuid = event.getConnection().getProfile().getId(); + Player player = Bukkit.getPlayer(event.getConnection().getProfile().getId()); + if (playerUuid == null || player == null) { + plugin.getLogger().warning("A player just spawned but their profile could not be retrieved, so we cannot check if they're allowed in this world or not. Check the above logs for the spawn event!"); + return; + } + + if (state.canBypass(player, world.getEnvironment().equals(World.Environment.NETHER) ? netherBypass : endBypass)) return; - // If the all of the above fail, set the spawn to the kick world - Location location = DimensionPausePlugin.ds.kickToWorld(event.getPlayer(), world.getEnvironment(), false); + Location location = state.kickToWorld(player, world.getEnvironment(), false); if (location != null) { event.setSpawnLocation(location); - DimensionState.alertPlayers.add(event.getPlayer().getUniqueId()); + state.alertPlayers.add(playerUuid); } } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java index b90f1e8..3f11a7a 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java @@ -1,5 +1,6 @@ package org.reprogle.dimensionpause.events; +import com.google.inject.Inject; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -7,12 +8,16 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerTeleportEvent; import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; public class PlayerTeleportEventListener implements Listener { + @Inject + ConfigManager configManager; + @Inject + DimensionState state; @EventHandler(priority = EventPriority.HIGHEST) - public static void onPlayerTeleport(PlayerTeleportEvent event) { + public void onPlayerTeleport(PlayerTeleportEvent event) { // If the teleport is localized within the world, ignore the event if (event.getFrom().getWorld().equals(event.getTo().getWorld())) { return; @@ -23,21 +28,21 @@ public static void onPlayerTeleport(PlayerTeleportEvent event) { if (env.equals(World.Environment.NORMAL)) return; // Grab the bypassable values for the nether and end. - boolean netherBypass = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - boolean endBypass = ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); + boolean netherBypass = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); + boolean endBypass = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); // If the environment the player is teleporting to is disabled, do the following - if (DimensionPausePlugin.ds.getState(env)) { + if (!state.getState(event.getTo().getWorld(), env).enabled()) { // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.canBypass(p, env.equals(World.Environment.NETHER) ? netherBypass : endBypass)) + if (state.canBypass(p, env.equals(World.Environment.NETHER) ? netherBypass : endBypass)) return; // If the all of the above fail cancel the event event.setCancelled(true); // Send the player the proper title for the environment they tried to access - DimensionPausePlugin.ds.alertPlayer(p, env); + state.alertPlayer(p, env); } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java index 99fde4c..6f683d8 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java @@ -1,48 +1,56 @@ package org.reprogle.dimensionpause.events; +import com.google.inject.Inject; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.world.PortalCreateEvent; import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; public class PortalCreateEventListener implements Listener { - @EventHandler() - public static void onPortalCreateEvent(PortalCreateEvent event) { - // We only want to disable the portal creation if a player lights it - if (!(event.getEntity() instanceof Player p)) return; - if(!DimensionPausePlugin.ds.getState(World.Environment.NETHER)) return; - - // We only want to check create reason of FIRE, because the other two, END_PLATFORM, and NETHER_PAIR, should never be cancelled - if (event.getReason().equals(PortalCreateEvent.CreateReason.FIRE)) { - - // Check if the nether is bypassable - boolean bypassable = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - - // Check if the nether is paused - if (DimensionPausePlugin.ds.getState(World.Environment.NETHER)) { - // If the player can bypass the nether, quit processing - if (DimensionPausePlugin.ds.canBypass(p, bypassable)) return; - - // Block portal creation - event.setCancelled(true); - - // Send the player the Nether title and chat messages, if configured - boolean sendTitle = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(CommandFeedback.getTitleForDimension(World.Environment.NETHER)); - } - - if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(World.Environment.NETHER)); - } - } - } - } + @Inject + DimensionState state; + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + + @EventHandler() + public void onPortalCreateEvent(PortalCreateEvent event) { + // We only want to disable the portal creation if a player lights it + if (!(event.getEntity() instanceof Player p)) return; + // We want to NOT block the creation of portals in the Nether, even if the Nether is disabled. Players should always be allowed to escape if necessary + if (p.getWorld().getEnvironment().equals(World.Environment.NETHER)) return; + // If the nether is NOT disabled for this world, ignore the event + if (state.getState(p.getWorld(), World.Environment.NETHER).enabled()) return; + + // We only want to check create reason of FIRE, because the other two, END_PLATFORM, and NETHER_PAIR, should never be cancelled + if (event.getReason().equals(PortalCreateEvent.CreateReason.FIRE)) { + + // Check if the nether is bypassable + boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); + + // If the player can bypass the nether, quit processing + if (state.canBypass(p, bypassable)) return; + + // Block portal creation + event.setCancelled(true); + + // Send the player the Nether title and chat messages, if configured + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); + + if (sendTitle) { + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); + } + + if (sendChat) { + p.sendMessage(commandFeedback.getChatForDimension(World.Environment.NETHER)); + } + } + } } diff --git a/src/main/java/org/reprogle/dimensionpause/store/Database.java b/src/main/java/org/reprogle/dimensionpause/store/Database.java new file mode 100644 index 0000000..f9d9b93 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/store/Database.java @@ -0,0 +1,103 @@ +package org.reprogle.dimensionpause.store; + +import org.bukkit.World; +import org.reprogle.dimensionpause.DimensionPausePlugin; + +import javax.annotation.Nullable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public abstract class Database { + private static final String WORLD_TABLE = "dimensionpause_worlds"; + private static final String SELECT = "SELECT enabled, expiresAt FROM "; + private static final String INSERT_INTO = "INSERT INTO "; + private static final String WHERE = " WHERE world = ? AND dimension = ? LIMIT 1;"; + + final DimensionPausePlugin plugin; + Connection connection; + + protected Database(DimensionPausePlugin plugin) { + this.plugin = plugin; + } + + public abstract Connection getSQLConnection(); + + public void setWorld(String world, World.Environment dimension, boolean enabled, @Nullable LocalDate expiresAt) { + Connection c = null; + PreparedStatement ps = null; + + try { + c = getSQLConnection(); + ps = c.prepareStatement( + INSERT_INTO + WORLD_TABLE + + " (world, dimension, enabled, updatedAt, expiresAt) " + + "VALUES (?, ?, ?, datetime('now'), ?) " + + "ON CONFLICT(world, dimension) DO UPDATE SET " + + "enabled = excluded.enabled, " + + "updatedAt = datetime('now'), " + + "expiresAt = CASE " + + "WHEN excluded.expiresAt IS NOT NULL THEN excluded.expiresAt " + + "WHEN excluded.enabled = 1 AND expiresAt IS NOT NULL AND expiresAt <= datetime('now') THEN NULL " + + "ELSE expiresAt " + + "END" + ); + + ps.setString(1, world); + ps.setString(2, dimension.toString()); + ps.setInt(3, enabled ? 1 : 0); + ps.setObject(4, expiresAt); + + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("Error while executing create SQL statement on block table: " + e); + } finally { + try { + if (ps != null) ps.close(); + if (c != null) c.close(); + } catch (SQLException e) { + plugin.getLogger().severe("Failed to close SQL Database connection: " + e); + } + } + } + + public WorldPauseStatus isWorldEnabled(String world, World.Environment dimension) { + try (Connection c = getSQLConnection(); PreparedStatement ps = c.prepareStatement(SELECT + WORLD_TABLE + WHERE)) { + ps.setString(1, world); + ps.setString(2, dimension.toString()); + + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return new WorldPauseStatus(false, null); + } + + boolean enabled = rs.getInt("enabled") == 1; + LocalDateTime expiresAt = null; + String expiresRaw = rs.getString("expiresAt"); + if (expiresRaw != null) { + expiresAt = LocalDateTime.parse(expiresRaw, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + enabled = enabled || (expiresAt != null && expiresAt.isBefore(LocalDateTime.now())); + + return new WorldPauseStatus(enabled, expiresAt); + } catch (SQLException e) { + plugin.getLogger().severe("Error while executing create SQL statement on block table: " + e); + } + } catch (SQLException e) { + plugin.getLogger().severe("Failed to close SQL Database connection: " + e); + } + + return new WorldPauseStatus(false, null); + } + + public record WorldPauseStatus( + boolean enabled, + LocalDateTime expiresAt + ) { + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/store/SQLite.java b/src/main/java/org/reprogle/dimensionpause/store/SQLite.java new file mode 100644 index 0000000..9b9f953 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/store/SQLite.java @@ -0,0 +1,143 @@ +package org.reprogle.dimensionpause.store; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import net.kyori.adventure.text.Component; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.store.patches.SQLitePatch; + +import java.io.File; +import java.io.IOException; +import java.sql.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; + +@Singleton +public class SQLite extends Database { + private final DimensionPausePlugin plugin; + private final Logger logger; + + private final List patches = new ArrayList<>(); + private final int DB_VERSION = 1; + + private final String SQLITE_CREATE_WORLDS_TABLE = "CREATE TABLE IF NOT EXISTS dimensionpause_worlds (" + + "`world` VARCHAR NOT NULL," + + "`dimension` VARCHAR NOT NULL," + + "`enabled` INTEGER NOT NULL," + + "`updatedAt` DATE NOT NULL," + + "`expiresAt` DATE NULL," + + "PRIMARY KEY (`world`, `dimension`)" + + ")"; + + private final String SET_PRAGMA = "PRAGMA user_version = " + DB_VERSION + ";"; + + @Inject + public SQLite(DimensionPausePlugin plugin, Logger logger) { + super(plugin); + this.logger = logger; + this.plugin = plugin; + + connection = getSQLConnection(); + try (Statement s = connection.createStatement()) { + PreparedStatement ps = connection.prepareStatement("PRAGMA user_version;"); + ResultSet rs = ps.executeQuery(); + int userVersion = rs.getInt("user_version"); + + boolean upgradeNecessary = checkIfUpgradeNecessary(connection, userVersion); + if (!upgradeNecessary) { + s.executeUpdate(SQLITE_CREATE_WORLDS_TABLE); + } else { + for (SQLitePatch patch : patches) { + // We're gonna close and reopen the connection for every patch to ensure a fresh connection and no locks + if (!connection.isClosed()) connection.close(); + + // Only apply the patch if the current version of the DB is less than the version of the DB patch + if (userVersion < patch.patchedIn()) { + // Apply the patch + connection = getSQLConnection(); + patch.update(connection, logger); + } + } + } + } catch (SQLException e) { + logger.severe("SQLException occurred while creating SQLite connection: " + e.getMessage()); + logger.severe("Full stack" + Arrays.toString(e.getStackTrace())); + } finally { + try { + if (connection != null) + connection.close(); + } catch (SQLException e) { + logger.severe("Failed to close SQLite Connection: " + e); + } + } + + connection = getSQLConnection(); + try (Statement s = connection.createStatement()) { + s.executeUpdate(SET_PRAGMA); + } catch ( + SQLException e) { + logger.severe("SQLException occurred while creating SQLite connection: " + e.getMessage()); + logger.severe("Full stack" + Arrays.toString(e.getStackTrace())); + } finally { + try { + if (connection != null) + connection.close(); + } catch (SQLException e) { + logger.severe("Failed to close SQLite Connection: " + e); + } + } + } + + public Connection getSQLConnection() { + File dataFolder = new File(plugin.getDataFolder(), "dimensionpause.db"); + if (!dataFolder.exists()) { + try { + boolean success = dataFolder.createNewFile(); + if (success) { + logger.info("Created data folder"); + } else { + logger.severe("Could not create data folder!"); + } + } catch (IOException e) { + logger.severe("Could not create dimensionpause.db file"); + } + } + + try { + if (connection != null && !connection.isClosed()) { + return connection; + } + Class.forName("org.sqlite.JDBC"); + connection = DriverManager.getConnection("jdbc:sqlite:" + dataFolder); + return connection; + + } catch (SQLException e) { + logger.severe("SQLite exception on initialize: " + e); + } catch (ClassNotFoundException e) { + logger.severe("SQLite JDBC Library not found. Please install this on your host to use SQLite: " + e); + plugin.getServer().getPluginManager().disablePlugin(plugin); + } + + return null; + } + + public boolean checkIfUpgradeNecessary(Connection connection, int userVersion) { + boolean alreadyInitialized; + boolean tablesExist; + + alreadyInitialized = userVersion >= DB_VERSION; + + // Then we check if any tables exist at all in the DB + try { + PreparedStatement ps = connection.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"); + ResultSet rs = ps.executeQuery(); + tablesExist = rs.next(); + } catch (SQLException e) { + tablesExist = false; + } + + return (!alreadyInitialized && tablesExist); + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/store/patches/SQLitePatch.java b/src/main/java/org/reprogle/dimensionpause/store/patches/SQLitePatch.java new file mode 100644 index 0000000..906f6ae --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/store/patches/SQLitePatch.java @@ -0,0 +1,24 @@ +package org.reprogle.dimensionpause.store.patches; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Logger; + +public interface SQLitePatch { + + /** + * The patch to apply + * + * @param c The connection + * @param logger The logger to log any potential errors + * @throws SQLException Thrown if an error occurs + */ + void update(Connection c, Logger logger) throws SQLException; + + /** + * The user_version pragma that the database patch applies to. This allows us to ignore unnecessary patches + * + * @return user_version of patch + */ + int patchedIn(); +} diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index da1526c..fc58198 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -1,5 +1,5 @@ # This is the version of the language file. If new translations are added, this will automatically update. DO NOT TOUCH THIS!!!!! -language-version: 3 +language-version: 4 # Language configs for this plugin use MiniMessage. If you need to know how to use MiniMessage, click the link below! # https://docs.advntr.dev/minimessage/format.html#standard-tags @@ -14,4 +14,7 @@ toggled: default: "The dimension has been toggled!" state: nether: "The Nether is currently " - end: "The End is currently " \ No newline at end of file + end: "The End is currently " + paused: "paused " + unpaused: "unpaused " + until: "until " \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 501768b..e033750 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,12 +3,16 @@ version: '@version@' main: org.reprogle.dimensionpause.DimensionPausePlugin api-version: '1.17' prefix: "DimensionPause" +folia-supported: true authors: [TerrorByteTW] description: Allows you to pause dimensions to prevent players from entering them commands: dimensionpause: description: Allows you to control the DimensionPause plugin aliases: [ dp ] +libraries: + - dev.dejvokep:boosted-yaml:1.3.7 + - com.google.inject:guice:7.0.0 permissions: dimensionpause.commands: From dd307373997acad8552c896c17d55d48fdb1b31e Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sun, 18 Jan 2026 19:53:46 -0600 Subject: [PATCH 6/8] - Multiworld support - Storage is now DB backed instead of config backed - Moved all language options into the language file - Temporarily pause worlds or indefinitely pause them - Reworked PlayerJoinEvent so we don't have to deal with asynchronicity --- README.md | 72 ++++-- build.gradle.kts | 2 +- .../dimensionpause/DimensionPauseModule.java | 1 + .../dimensionpause/DimensionPausePlugin.java | 40 +--- .../dimensionpause/DimensionState.java | 133 ----------- .../commands/CommandFeedback.java | 170 ++++++------- .../commands/subcommands/Reload.java | 7 +- .../commands/subcommands/State.java | 8 +- .../commands/subcommands/Toggle.java | 24 +- .../EntityPortalEnterEventListener.java | 185 --------------- .../events/ListenerManager.java | 14 +- .../events/PlayerInteractEventListener.java | 36 +-- .../events/PlayerJoinEventListener.java | 55 ++++- .../events/PlayerPortalEventListener.java | 163 +++++++++++++ .../PlayerSpawnLocationEventListener.java | 70 ------ .../events/PlayerTeleportEventListener.java | 85 ++++--- .../events/PortalCreateEventListener.java | 87 +++---- .../dimensionpause/events/WorldLoadEvent.java | 16 ++ .../dimensionpause/store/Database.java | 88 ++++--- .../reprogle/dimensionpause/store/SQLite.java | 3 +- .../{ => utils}/ConfigManager.java | 2 +- .../utils/DimensionExpirationTimer.java | 97 ++++++++ .../dimensionpause/utils/DimensionState.java | 224 ++++++++++++++++++ .../dimensionpause/utils/InstantParser.java | 55 +++++ .../dimensionpause/utils/WorldUtils.java | 24 ++ src/main/resources/config.yml | 52 ++-- src/main/resources/lang/en_US.yml | 66 ++++-- src/main/resources/plugin.yml | 4 - 28 files changed, 1044 insertions(+), 739 deletions(-) delete mode 100644 src/main/java/org/reprogle/dimensionpause/DimensionState.java delete mode 100644 src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java create mode 100644 src/main/java/org/reprogle/dimensionpause/events/PlayerPortalEventListener.java delete mode 100644 src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java create mode 100644 src/main/java/org/reprogle/dimensionpause/events/WorldLoadEvent.java rename src/main/java/org/reprogle/dimensionpause/{ => utils}/ConfigManager.java (98%) create mode 100644 src/main/java/org/reprogle/dimensionpause/utils/DimensionExpirationTimer.java create mode 100644 src/main/java/org/reprogle/dimensionpause/utils/DimensionState.java create mode 100644 src/main/java/org/reprogle/dimensionpause/utils/InstantParser.java create mode 100644 src/main/java/org/reprogle/dimensionpause/utils/WorldUtils.java diff --git a/README.md b/README.md index def1189..fb84f78 100644 --- a/README.md +++ b/README.md @@ -10,28 +10,70 @@ # Dimension Pause ๐ŸŒŽโŒš ## What is Dimension Pause? -Dimension Pause is a super simple, lightweight plugin that allows you to temporarily block players from creating dimension portals or entering dimensions. -It works by detecting players attempting to create portals, or detecting when a player switches worlds (Such as entering an already-existing portal, or using Essentials's `/home` feature). When this happens, -if the world is paused and certain criteria is not met, the player is either blocked from creating the portal, or kicked out of the world. +Dimension Pause is a super simple, lightweight plugin that allows you to temporarily block players from creating +dimension portals or entering dimensions. -If the player is currently in a dimension when it is disabled, then they are kicked out to either their bed or a world defined in config. +It works by detecting players attempting to create portals, or detecting when a player switches worlds (Such as entering +an already-existing portal, or using Essentials's `/home` feature). When this happens, +if that world's dimension is paused and certain criteria are not met, the player is either blocked from creating the +portal, or kicked out of the world. + +If the player is currently in a dimension when it is disabled, then they are kicked out to either their respawn location +or a world defined in config if their respawn location is unavailable. + +## Current Features + +* Completely block access to dimensions _per world_. See the "World Setup" section below for details + * Players cannot create portals, enter portals or teleport via commands (Such as `/home` or `/warp`) to other + dimensions. If the player was in a dimension that was paused while they were logged off, upon logging back on they + will be teleported out after a configurable delay. +* Pause worlds until manually unpaused by server staff, or after a delay + * Dimensions, by default, are paused indefinitely. However, you can specify a delay to pause the world for a + duration. +* Supports custom translations/formatting for chat messages and titles. You are not locked to "DimensionPause" branding! + * Upon loading the server, check out the `plugins/DimensionPause/lang/` folder for configuration options. +* Persistent & resilient expiration timers. Dimensions will not stay paused by accident if your server restarts or + crashes! + * DimensionPause uses Paper's native scheduler, and will (re)schedule timers for ALL temporarily paused dimensions + on server start, dimension toggle, and world load. +* Folia Support + * NOTE: Folia is **NOT TESTED**. While the plugin has been written with Folia in mind, we have not actually run it on + Folia yet due to dependencies like LuckPerms not working on Folia yet. Use at your own risk, Folia will be tested + in the next version ## Future Features -* Support multiple worlds as well as dimensions - * Currently, if you create multiple Nether worlds with a multi-world plugin, such as MultiVerse, you can only disable *all* Nether worlds, not just specific ones. + * Support Velocity / BungeeCord -* Temporarily disable dimensions (Disable dimensions for an hour, for example) * Create an API for developers to integrate with DimensionPause ## Commands & Permissions + All commands may substitute `/dimensionpause` with `/dp` for conciseness -| Command | Permission | Description | -|----------------------------------------|-------------------------|-----------------------------------------------------------------------------------------------| -| /dimensionpause | dimensionpause.commands | Displays help menu | -| /dimensionpause toggle | dimensionpause.toggle | Pauses or unpauses a given dimension type | -| /dimensionpause state | dimensionpause.state | Checks the state of a given dimension type | -| /dimensionpause reload | dimensionpause.reload | Reloads DimensionPause configs and language files | -| | dimensionpause.bypass | Allows players to bypass a bypassable world. If a world is not bypassable, only Ops may enter | -| | dimensionpause.* | Grants all permissions listed above | +| Command | Permission | Description | +|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| /dimensionpause | dimensionpause.commands | Displays help menu | +| /dimensionpause toggle \[\w\]\[\d\]\[\h\]\[\m\]\[\s\] | dimensionpause.toggle | Pauses or unpauses a given dimension type for a specific world, with an optional duration. The duration is how long from now the pause will expire. | +| /dimensionpause state | dimensionpause.state | Checks the state of a given dimension type for a specific world | +| /dimensionpause reload | dimensionpause.reload | Reloads DimensionPause configs and language files | +| | dimensionpause.bypass.[world].[dimension] | Allows players to bypass a pause for a given world & dimension | +| | dimensionpause.* | Grants all permissions listed above | + +## Requirements + +DimensionPause 1.1.2 works for 1.17 and up. However, DimensionPause 2.0.0 requires the latest version of Paper (At the +time of writing, 1.21.11 & Java 21). Supporting older versions of Paper while maintaining _forward_ compatibility is a +massive pain. Out of the almost 100 servers running DimensionPause, less than 5% of servers are running a version of +Paper +unsupported by this plugin, and almost 75% of servers are supported. + +## World Setup + +DimensionPause works under the assumption that your Nether and End dimensions will be connected to +an Overworld dimension. DimensionPause does *not* support pausing dimensions for nether-only or end-only worlds, as they +do not have an associated overworld. + +Since Paper does not link worlds to each other, instead it's assumed nether and end worlds follow the standard "_nether" +or "_the_end" naming conventions. For example, the default `world` dimension worlds should be called `world_nether` and +`world_the_end`. **If your worlds are not named like this, you cannot use DimensionPause**. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index df77d71..1850a0f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ plugins { } project.group = "org.reprogle" -project.version = "1.2.0" +project.version = "2.0.0" project.description = "Allows you to pause dimensions to prevent players from entering them" val isReleaseBuild = project.hasProperty("releaseBuild") diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java index 80d8628..e1f7710 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java @@ -9,6 +9,7 @@ import org.reprogle.dimensionpause.commands.subcommands.Reload; import org.reprogle.dimensionpause.commands.subcommands.State; import org.reprogle.dimensionpause.commands.subcommands.Toggle; +import org.reprogle.dimensionpause.utils.ConfigManager; public class DimensionPauseModule extends AbstractModule { private final DimensionPausePlugin plugin; diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java index fe4e10e..375f9fc 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java @@ -2,23 +2,25 @@ import com.google.inject.Inject; import com.google.inject.Injector; +import lombok.Getter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.plugin.java.JavaPlugin; -import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.CommandManager; import org.reprogle.dimensionpause.events.ListenerManager; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.utils.DimensionExpirationTimer; public final class DimensionPausePlugin extends JavaPlugin { - @Inject - private CommandFeedback commandFeedback; + ListenerManager listenerManager; @Inject - private ListenerManager listenerManager; + CommandManager commandManager; @Inject - private CommandManager commandManager; + DimensionExpirationTimer timer; + @Getter private Injector injector; @Override @@ -39,31 +41,15 @@ public void onEnable() { getLogger().info("Dimension Pause has been loaded"); if (this.getDescription().getVersion().contains("SNAPSHOT")) { - Component updateMessage = Component.text() - .append(commandFeedback.getChatPrefix()) - .append(Component.text(" ")) - .append(Component.text("You are running a SNAPSHOT version of DimensionPause. Support will not be provided!", NamedTextColor.RED)) - .build(); + Component updateMessage = Component.text("You are running a SNAPSHOT version of DimensionPause. Support will not be provided!", NamedTextColor.RED); getServer().getConsoleSender().sendMessage(updateMessage); } else { new UpdateChecker(this, "https://raw.githubusercontent.com/TerrorByteTW/DimensionPause/master/version.txt").getVersion(latest -> { if (Integer.parseInt(latest.replace(".", "")) > Integer.parseInt(this.getDescription().getVersion().replace(".", ""))) { - Component updateMessage = Component.text() - .append(commandFeedback.getChatPrefix()) - .append(Component.text(" ")) - .append(Component.text("There is a new update available: " + latest + ". Please download for the latest features and security updates!", NamedTextColor.RED)) - .build(); - - getServer().getConsoleSender().sendMessage(updateMessage); + getServer().getConsoleSender().sendMessage(Component.text("There is a new update available for DimensionPause: " + latest + ". Please download for the latest features and security updates!", NamedTextColor.RED)); } else { - Component noUpdateMessage = Component.text() - .append(commandFeedback.getChatPrefix()) - .append(Component.text(" ")) - .append(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)) - .build(); - - getServer().getConsoleSender().sendMessage(noUpdateMessage); + getServer().getConsoleSender().sendMessage(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)); } }); } @@ -72,6 +58,8 @@ public void onEnable() { getServer().getConsoleSender().sendMessage( Component.text("Welcome to Folia!!!! It is assumed you know what you're doing, since Folia is not yet standard. While DimensionPause can run on Folia, it is not yet officially endorsed by the developer, and is also not actively tested. Be wary when using it for now, and report any bugs in Honeypot caused by Folia to the developer!")); } + + timer.refresh(); } @Override @@ -79,10 +67,6 @@ public void onDisable() { getLogger().info("Dimension Pause is shutting down"); } - public Injector getInjector() { - return injector; - } - private boolean isFolia() { return Bukkit.getServer().getName().startsWith("Folia"); } diff --git a/src/main/java/org/reprogle/dimensionpause/DimensionState.java b/src/main/java/org/reprogle/dimensionpause/DimensionState.java deleted file mode 100644 index 68ea038..0000000 --- a/src/main/java/org/reprogle/dimensionpause/DimensionState.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.reprogle.dimensionpause; - -import com.google.inject.Inject; -import com.google.inject.Singleton; -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.Nullable; -import org.reprogle.dimensionpause.commands.CommandFeedback; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; -import java.util.logging.Level; - -import org.bukkit.Location; -import org.reprogle.dimensionpause.store.Database; -import org.reprogle.dimensionpause.store.SQLite; - -@Singleton -public class DimensionState { - - public final Set alertPlayers = new HashSet<>(); - @Inject - private ConfigManager configManager; - @Inject - private DimensionPausePlugin plugin; - @Inject - private CommandFeedback commandFeedback; - @Inject - private SQLite db; - - public void toggleDimension(World world, World.Environment dimension, @Nullable LocalDate expirationTime) { - toggleDimension(world.getName(), dimension, expirationTime); - } - - public void toggleDimension(String world, World.Environment dimension, @Nullable LocalDate expirationTime) { - Collection players = plugin.getServer().getOnlinePlayers(); - - boolean worldDimensionEnabled = db.isWorldEnabled(world, dimension).enabled(); - db.setWorld(world, dimension, !worldDimensionEnabled, expirationTime); - - alertOfStateChange(players, world, dimension, !worldDimensionEnabled); - - if (!worldDimensionEnabled) { - boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions." + (dimension.equals(World.Environment.NETHER) ? "nether" : "end") + ".bypassable"); - for (Player player : plugin.getServer().getOnlinePlayers()) { - if (player.getWorld().getEnvironment().equals(dimension) && !canBypass(player, bypassable)) { - kickToWorld(player, dimension, true); - } - } - } - } - - /** - * A helper method to kick a player to a world, OR to get the location of the place they'd be spawned at (In the case of Async events) - * - * @param player The Player being kicked - * @param dimension The dimension the player was kicked FROM - * @param teleport Whether to teleport the player once the respawn location is confirmed - * @return The Location the player was/will be teleported to - */ - @Nullable - public Location kickToWorld(Player player, World.Environment dimension, boolean teleport) { - Location loc = player.getRespawnLocation(); - - if (configManager.getPluginConfig().getBoolean("try-bed-first") && loc != null) { - if (teleport) player.teleportAsync(loc); - } else { - World world = Bukkit.getWorld(configManager.getPluginConfig().getString("kick-world")); - - // We can't teleport the player if the kick-world is invalid, so we must return null - if (world == null) { - plugin.getLogger().log(Level.WARNING, "IMPORTANT MESSAGE! A world has been paused, but at least one player is still in it ({0}). This player doesn't have a valid respawn location, and the kick-world configured in config was not obtainable, so we cannot teleport players out of the world. Please intervene!", player.getName()); - return null; - } - - // Teleport the player asynchronously (Folia) to the kick-world's spawn - if (teleport) player.teleportAsync(world.getSpawnLocation()); - loc = world.getSpawnLocation(); - } - - // If we teleported the player, alert them - if (teleport) { - alertPlayer(player, dimension); - } - - return loc; - } - - public Database.WorldPauseStatus getState(World world, World.Environment dimension) { - return getState(world.getName(), dimension); - } - - public Database.WorldPauseStatus getState(String world, World.Environment dimension) { - return db.isWorldEnabled(world, dimension); - } - - public boolean canBypass(Player player, boolean bypassableFlag) { - if (player.isOp()) return true; - if (!bypassableFlag) return false; - return player.hasPermission("dimensionpause.bypass"); - } - - public void alertPlayer(Player player, World.Environment dimension) { - String env = dimension.equals(World.Environment.NETHER) ? "nether" : "end"; - boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.title.enabled"); - boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.chat.enabled"); - - if (sendTitle) { - player.showTitle(commandFeedback.getTitleForDimension(dimension)); - } - - if (sendChat) { - player.sendMessage(commandFeedback.getChatForDimension(dimension)); - } - } - - private void alertOfStateChange(Collection players, String world, World.Environment environment, boolean newState) { - // Get a string value for the dimension. This is useful later on. - String env = environment.equals(World.Environment.NETHER) ? "nether" : "end"; - - if (!configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.on-toggle.enabled")) return; - - for (Player player : players) { - player.sendMessage(commandFeedback.getToggleMessageForDimension(world, environment, newState)); - } - - } -} diff --git a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java index ec1e745..72abe8e 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/CommandFeedback.java @@ -6,16 +6,17 @@ import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.title.Title; import org.bukkit.World; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.utils.DimensionState; import org.reprogle.dimensionpause.store.Database; import javax.annotation.Nullable; import java.time.Duration; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.Objects; public class CommandFeedback { @@ -26,15 +27,6 @@ public class CommandFeedback { @Inject private DimensionState state; - /** - * Return the chat prefix object from config - * - * @return The chat prefix, preformatted with color and other modifiers - */ - public Component getChatPrefix() { - return mm.deserialize(Objects.requireNonNull(configManager.getLanguageFile().getString("prefix"))); - } - /** * A helper class which helps to reduce boilerplate player.sendMessage code by providing the strings to send instead * of having to copy and paste them. @@ -42,56 +34,44 @@ public Component getChatPrefix() { * @param feedback The string to send back * @return The Feedback string */ - public Component sendCommandFeedback(String feedback, @Nullable String world, @Nullable String dimension) { + public Component sendCommandFeedback(String feedback, @Nullable World world, @Nullable String dimension) { Component feedbackMessage; - Component chatPrefix = getChatPrefix(); YamlDocument languageFile = configManager.getLanguageFile(); World.Environment environment = null; if (dimension != null && (dimension.equalsIgnoreCase("end") || dimension.equalsIgnoreCase("nether"))) environment = (dimension.equalsIgnoreCase("nether") ? World.Environment.NETHER : World.Environment.THE_END); + final Component untilComponent = mm.deserialize(languageFile.getString("state.until")); + switch (feedback.toLowerCase()) { - case "usage" -> - feedbackMessage = Component.text().content("\n \n \n \n \n \n-----------------------\n \n").color(NamedTextColor.WHITE) - .append(chatPrefix).append(Component.text(" ")) - .append(Component.text("Need help?\n \n", NamedTextColor.WHITE)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("toggle [end | nether] \n", NamedTextColor.GRAY)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("state [end | nether] \n", NamedTextColor.GRAY)) - .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("reload \n \n", NamedTextColor.GRAY)) - .append(Component.text("-----------------------", NamedTextColor.WHITE)) - .build(); - case "nopermission" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("no-permission"))) - .build(); - case "reload" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("reload"))) - .build(); - case "io-exception" -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("io-exception"))) - .build(); + case "usage" -> { + final Component prefixComponent = mm.deserialize(languageFile.getString("state.until")); + feedbackMessage = Component.text().content("\n \n \n \n \n \n-----------------------\n \n").color(NamedTextColor.WHITE) + .append(prefixComponent).append(Component.text(" ")) + .append(Component.text("Need help?\n \n", NamedTextColor.WHITE)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("toggle [w][d][h][m][s] \n", NamedTextColor.GRAY)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("state \n", NamedTextColor.GRAY)) + .append(Component.text(" /dimensionpause ", NamedTextColor.WHITE)).append(Component.text("reload \n \n", NamedTextColor.GRAY)) + .append(Component.text("-----------------------", NamedTextColor.WHITE)) + .build(); + } + case "nopermission" -> feedbackMessage = deserialize(languageFile.getString("no-permission"), false, null); + case "reload" -> feedbackMessage = deserialize(languageFile.getString("reload"), false, null); + case "io-exception" -> feedbackMessage = deserialize(languageFile.getString("io-exception"), false, null); case "newstate" -> { - Component pausedComponent = mm.deserialize(languageFile.getString("state.paused")); - Component unpausedComponent = mm.deserialize(languageFile.getString("state.unpaused")); - if (environment == null) { - feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("toggled.default"))) - .build(); + if (environment == null || world == null) { + feedbackMessage = deserialize(languageFile.getString("toggled.default"), false, null); } else { Database.WorldPauseStatus worldState = state.getState(world, environment); - TextComponent.Builder builder = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("toggled." + dimension))) - .append(!worldState.enabled() ? pausedComponent : unpausedComponent); + TextComponent.Builder builder = Component.text().append(deserialize(languageFile.getString("toggled." + dimension), worldState.enabled(), world.getName())); - if (worldState.expiresAt() != null) { - Component untilComponent = mm.deserialize(languageFile.getString("state.until")); - builder.append(untilComponent) - .append(Component.text(worldState.expiresAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))) + // Only output the expiration time if disabled + if (worldState.expiresAt() != null && !worldState.enabled()) { + builder.append(Component.text(" ")) + .append(untilComponent) + .append(Component.text(" ")) + .append(Component.text(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC).format(worldState.expiresAt()))) .append(Component.text(" UTC")); } @@ -99,29 +79,22 @@ public Component sendCommandFeedback(String feedback, @Nullable String world, @N } } case "state" -> { - Component pausedComponent = mm.deserialize(languageFile.getString("state.paused")); - Component unpausedComponent = mm.deserialize(languageFile.getString("state.unpaused")); - if (environment == null) return Component.empty(); + if (environment == null || world == null) return Component.empty(); Database.WorldPauseStatus worldState = state.getState(world, environment); - TextComponent.Builder builder = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("state." + dimension))) - .append(!worldState.enabled() ? pausedComponent : unpausedComponent); - - if (worldState.expiresAt() != null) { - Component untilComponent = mm.deserialize(languageFile.getString("state.until")); - builder.append(untilComponent) - .append(Component.text(worldState.expiresAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))) + TextComponent.Builder builder = Component.text().append(deserialize(languageFile.getString("state." + dimension), worldState.enabled(), world.getName())); + + if (worldState.expiresAt() != null && !worldState.enabled()) { + builder.append(Component.text(" ")) + .append(untilComponent) + .append(Component.text(" ")) + .append(Component.text(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC).format(worldState.expiresAt()))) .append(Component.text(" UTC")); } feedbackMessage = builder.build(); } - default -> feedbackMessage = Component.text().append(chatPrefix) - .append(Component.text(" ")) - .append(mm.deserialize(languageFile.getString("unknown-error"))) - .build(); + default -> feedbackMessage = deserialize(languageFile.getString("unknown-error"), false, null); } return feedbackMessage; @@ -130,35 +103,62 @@ public Component sendCommandFeedback(String feedback, @Nullable String world, @N public Title getTitleForDimension(World.Environment env) { String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - final Component mainTitle = Component.text().append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.title"))).build(); - final Component subtitle = Component.text().append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.title.subtitle"))).build(); + final Component mainTitle = Component.text().append(mm.deserialize(configManager.getLanguageFile().getString("alert." + environment + ".title.title"))).build(); + final Component subtitle = Component.text().append(mm.deserialize(configManager.getLanguageFile().getString("alert." + environment + ".title.subtitle"))).build(); final Title.Times times = Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); return Title.title(mainTitle, subtitle, times); } - public Component getChatForDimension(World.Environment env) { + public Title getTitleForTeleport(int secondsRemaining, boolean fadeInTitle) { + NamedTextColor numColor = (secondsRemaining >= 3) ? NamedTextColor.GOLD + : (secondsRemaining == 2) ? NamedTextColor.RED + : NamedTextColor.DARK_RED; + + final Component mainTitle = Component.text().append(mm.deserialize(configManager.getLanguageFile().getString("preteleport.title"))).build(); + final Component subtitle = Component.text() + .append(mm.deserialize(configManager.getLanguageFile().getString("preteleport.subtitle"))) + .append(Component.text(secondsRemaining, numColor)).build(); + + return Title.title( + mainTitle, + subtitle, + Title.Times.times(fadeInTitle ? Duration.ofMillis(500) : Duration.ZERO, Duration.ofMillis(1000), Duration.ZERO) + ); + } + + public Component getDimensionIsPausedMessage(World.Environment env) { String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - return Component.text() - .append(getChatPrefix()) - .append(mm.deserialize(configManager.getPluginConfig().getString("dimensions." + environment + ".alert.chat.message"))) - .build(); + return deserialize(configManager.getLanguageFile().getString("alert." + environment + ".chat"), false, null); } - public Component getToggleMessageForDimension(World world, World.Environment env, boolean newState) { - return getToggleMessageForDimension(world.getName(), env, newState); + public Component getStateChangedMessage(World world, World.Environment env, boolean enabled) { + return getStateChangedMessage(world.getName(), env, enabled); } - public Component getToggleMessageForDimension(String world, World.Environment env, boolean newState) { + public Component getStateChangedMessage(String world, World.Environment env, boolean enabled) { String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; - String stateParsed = newState ? "paused" : "unpaused"; - String worldFmtd = "" + world + ""; - - String preparsedText = configManager.getPluginConfig().getString("dimensions." + environment + ".alert.on-toggle.message").replace("%world%", worldFmtd).replace("%state%", stateParsed); - return Component.text() - .append(getChatPrefix()) - .append(Component.text(" ")) - .append(mm.deserialize(preparsedText)) - .build(); + return deserialize(configManager.getLanguageFile().getString("alert." + environment + ".on-toggle"), enabled, world); + } + + private Component deserialize(String serializedString, boolean worldState, String worldName) { + final Component prefixComponent = mm.deserialize(configManager.getLanguageFile().getString("prefix")); + final Component pausedComponent = mm.deserialize(configManager.getLanguageFile().getString("state.paused")); + final Component unpausedComponent = mm.deserialize(configManager.getLanguageFile().getString("state.unpaused")); + + return mm.deserialize(serializedString, + Placeholder.component( + "prefix", + prefixComponent + ), + Placeholder.component( + "state", + worldState ? unpausedComponent : pausedComponent + ), + Placeholder.component( + "world", + Component.text(worldName != null ? worldName : "", NamedTextColor.BLUE) + ) + ); } } diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java index 7620b42..6aaaf08 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java @@ -2,9 +2,10 @@ import com.google.inject.Inject; import org.bukkit.command.CommandSender; -import org.reprogle.dimensionpause.ConfigManager; +import org.reprogle.dimensionpause.utils.ConfigManager; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.SubCommand; +import org.reprogle.dimensionpause.utils.DimensionExpirationTimer; import java.io.IOException; import java.util.ArrayList; @@ -15,6 +16,8 @@ public class Reload implements SubCommand { ConfigManager configManager; @Inject CommandFeedback commandFeedback; + @Inject + DimensionExpirationTimer timer; @Override public String getName() { @@ -30,6 +33,8 @@ public void perform(CommandSender sender, String[] args) { configManager.getLanguageFile().reload(); configManager.getLanguageFile().save(); + timer.refresh(); + sender.sendMessage(commandFeedback.sendCommandFeedback("reload", null, null)); } catch (IOException e) { diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java index afa4caf..7b6c545 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/State.java @@ -22,7 +22,13 @@ public String getName() { @Override public void perform(CommandSender sender, String[] args) { if (args.length >= 3) { - String world = args[1]; + World world = Bukkit.getWorld(args[1]); + + if (world == null) { + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); + return; + } + String dimension = args[2].toLowerCase(); sender.sendMessage(commandFeedback.sendCommandFeedback("state", world, dimension)); } else { diff --git a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java index cb1c78c..1860f45 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java @@ -4,10 +4,12 @@ import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.CommandSender; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.utils.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; import org.reprogle.dimensionpause.commands.SubCommand; +import org.reprogle.dimensionpause.utils.InstantParser; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -25,10 +27,26 @@ public String getName() { @Override public void perform(CommandSender sender, String[] args) { if (args.length >= 3 && (args[2].equalsIgnoreCase("end") || args[2].equalsIgnoreCase("nether"))) { - String world = args[1]; + World world = Bukkit.getWorld(args[1]); + if (world == null) { + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); + return; + } + + Instant pauseExpiration = null; + + if (args.length >= 4) { + try { + pauseExpiration = InstantParser.parseFutureInstant(args[3]); + } catch (IllegalArgumentException e) { + sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); + return; + } + } + String dimension = args[2].toLowerCase(); World.Environment environment = dimension.equalsIgnoreCase("nether") ? World.Environment.NETHER : World.Environment.THE_END; - state.toggleDimension(world, environment, null); + state.setDimensionState(world, environment, pauseExpiration); sender.sendMessage(commandFeedback.sendCommandFeedback("newstate", world, dimension)); } else { sender.sendMessage(commandFeedback.sendCommandFeedback("usage", null, null)); diff --git a/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java deleted file mode 100644 index 3007df0..0000000 --- a/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.reprogle.dimensionpause.events; - -import com.google.inject.Inject; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityPortalEnterEvent; -import org.bukkit.potion.PotionEffect; -import org.bukkit.potion.PotionEffectType; -import org.bukkit.util.Vector; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; -import org.reprogle.dimensionpause.DimensionState; -import org.reprogle.dimensionpause.commands.CommandFeedback; - -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -public class EntityPortalEnterEventListener implements Listener { - - @Inject - ConfigManager configManager; - @Inject - CommandFeedback commandFeedback; - @Inject - DimensionState state; - @Inject - DimensionPausePlugin plugin; - - private final Set playersBeingHandled = new HashSet<>(); - - // Handler for nether portals - @EventHandler(priority = EventPriority.HIGHEST) - public void onNetherPortalEnter(EntityPortalEnterEvent event) { - // Check if the event is a player, if nether bounce-back option is enabled, and if the nether is currently paused - if (!(event.getEntity() instanceof Player p) || !configManager.getPluginConfig().getBoolean("dimensions.nether.bounce-back") || state.getState(p.getWorld(), World.Environment.NETHER).enabled()) { - return; - } - - Location currentLocation = event.getLocation().set(event.getLocation().getBlockX(), event.getLocation().getBlockY(), event.getLocation().getBlockZ()); - - if (currentLocation.getBlock().getType() != Material.NETHER_PORTAL) { - return; - } - - // If the player can bypass the environment, quit processing - if (state.canBypass(p, configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"))) { - return; - } - - event.setCancelled(true); - - // Ensure this event is not already being handled - if (playersBeingHandled.contains(p.getUniqueId())) { - return; - } - playersBeingHandled.add(p.getUniqueId()); - - // Send the player back a bit to emphasize the pause - // Modified from https://github.com/Multiverse/Multiverse-NetherPortals/blob/7a46c67f0a06064fe7f0e4f7b99aa00afc0c5e25/src/main/java/com/onarandombox/MultiverseNetherPortals/listeners/MVNPEntityListener.java#L77-L127 - double newVecX; - double newVecZ; - double strength = 1; - - Block block = currentLocation.getBlock(); - // determine portal orientation by checking if the block to the west/east is also a nether portal block - if (block.getRelative(BlockFace.WEST).getType() == Material.NETHER_PORTAL || block.getRelative(BlockFace.EAST).getType() == Material.NETHER_PORTAL) { - newVecX = 0; - // we add 0.5 to the location of the block to get the center - if (p.getLocation().getZ() < block.getLocation().getZ() + 0.5) { - // Entered from the North - newVecZ = -1 * strength; - } else { - // Entered from the South - newVecZ = 1 * strength; - } - } else { - newVecZ = 0; - // we add 0.5 to the location of the block to get the center - if (p.getLocation().getX() < block.getLocation().getX() + 0.5) { - // Entered from the West - newVecX = -1 * strength; - } else { - // Entered from the East - newVecX = 1 * strength; - } - } - - // Delay the velocity and removal of the player from the set - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); - p.setVelocity(new Vector(newVecX, .7, newVecZ)); - playersBeingHandled.remove(p.getUniqueId()); - }, 1L); // 1 tick or 1/20 of a second - - boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); - boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); - } - - if (sendChat) { - p.sendMessage(commandFeedback.getChatForDimension(World.Environment.NETHER)); - } - } - - // Handler for end portals - @EventHandler(priority = EventPriority.HIGHEST) - public void onEndPortalEnter(EntityPortalEnterEvent event) { - // Check if the event is a player, if the end bounce-back option is enabled, and if the end is currently paused - if (!(event.getEntity() instanceof Player p) || !configManager.getPluginConfig().getBoolean("dimensions.end.bounce-back") || state.getState(p.getWorld(), World.Environment.THE_END).enabled()) { - return; - } - - Location currentLocation = event.getLocation().set(event.getLocation().getBlockX(), event.getLocation().getBlockY(), event.getLocation().getBlockZ()); - - if (currentLocation.getBlock().getType() != Material.END_PORTAL) { - return; - } - - // If the player can bypass the environment, quit processing - if (state.canBypass(p, configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"))) { - return; - } - - event.setCancelled(true); - - // Ensure this event is not already being handled - if (playersBeingHandled.contains(p.getUniqueId())) { - return; - } - - playersBeingHandled.add(p.getUniqueId()); - - float yaw = p.getLocation().getYaw(); - - double radians = Math.toRadians(yaw); - - double x = Math.sin(radians); - double z = -Math.cos(radians); - - Vector knockbackDirection = new Vector(x, 0.7, z); - - knockbackDirection.multiply(0.7); - p.setVelocity(knockbackDirection); - - // Delay the velocity and removal of the player from the set - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); - playersBeingHandled.remove(p.getUniqueId()); - }, 5L); // 1 tick or 1/20 of a second - - boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); - boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(commandFeedback.getTitleForDimension(World.Environment.THE_END)); - } - - if (sendChat) { - p.sendMessage(commandFeedback.getChatForDimension(World.Environment.THE_END)); - } - } - - - // Small event listener to handle cases such as fall damage - @EventHandler - public void onPlayerDamageEvent(EntityDamageEvent event) { - if (!(event.getEntity() instanceof Player p)) return; - - if (playersBeingHandled.contains(p.getUniqueId())) { - p.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 10, 5, false, false)); - } - } - -} diff --git a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java index 9dbe7cf..b17716c 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java +++ b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java @@ -3,7 +3,6 @@ import com.google.inject.Inject; import org.bukkit.event.Listener; import org.bukkit.plugin.PluginManager; -import org.reprogle.dimensionpause.ConfigManager; import org.reprogle.dimensionpause.DimensionPausePlugin; import java.util.ArrayList; @@ -13,8 +12,6 @@ public class ListenerManager { private final DimensionPausePlugin plugin; - @Inject - PlayerSpawnLocationEventListener playerSpawnLocationEventListener; @Inject PlayerJoinEventListener playerJoinEventListener; @Inject @@ -24,7 +21,7 @@ public class ListenerManager { @Inject PortalCreateEventListener portalCreateEventListener; @Inject - EntityPortalEnterEventListener entityPortalEnterEventListener; + PlayerPortalEventListener playerPortalEventListener; @Inject ListenerManager(DimensionPausePlugin plugin) { @@ -35,10 +32,13 @@ public class ListenerManager { * Set's up all the listeners in the entire plugin */ public void setupListeners() { - final List listeners = new ArrayList<>(List.of(playerSpawnLocationEventListener, - playerJoinEventListener, playerTeleportEventListener, playerInteractEventListener, - portalCreateEventListener, entityPortalEnterEventListener)); PluginManager pm = plugin.getServer().getPluginManager(); + final List listeners = new ArrayList<>(List.of( + playerJoinEventListener, + playerTeleportEventListener, + playerInteractEventListener, + portalCreateEventListener, + playerPortalEventListener)); listeners.forEach(event -> pm.registerEvents(event, plugin)); } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java index 68b6271..84b1c3d 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerInteractEventListener.java @@ -8,9 +8,8 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.utils.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; public class PlayerInteractEventListener implements Listener { @@ -23,26 +22,27 @@ public class PlayerInteractEventListener implements Listener { @EventHandler() public void onPlayerInteractEvent(PlayerInteractEvent event) { - if (event.getAction() == Action.RIGHT_CLICK_BLOCK && event.getClickedBlock() != null && event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) { - World world = event.getPlayer().getWorld(); - if (state.getState(world, World.Environment.THE_END).enabled()) return; + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + if (event.getClickedBlock() == null) return; + if (!event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) return; + if (event.getMaterial() != Material.ENDER_EYE) return; - boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); + World world = event.getPlayer().getWorld(); + if (state.getState(world, World.Environment.THE_END).enabled()) return; - if (state.canBypass(event.getPlayer(), bypassable)) return; - event.setCancelled(true); - Player p = event.getPlayer(); + if (state.canBypass(event.getPlayer(), world, World.Environment.THE_END)) return; + event.setCancelled(true); + Player p = event.getPlayer(); - boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.end.alert.title.enabled"); - boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat.enabled"); + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.end.alert.title"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat"); - if (sendTitle) { - p.showTitle(commandFeedback.getTitleForDimension(World.Environment.THE_END)); - } + if (sendTitle) { + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.THE_END)); + } - if (sendChat) { - p.sendMessage(commandFeedback.getChatForDimension(World.Environment.THE_END)); - } + if (sendChat) { + p.sendMessage(commandFeedback.getDimensionIsPausedMessage(World.Environment.THE_END)); } } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java index 0d14104..2063439 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java @@ -1,30 +1,67 @@ package org.reprogle.dimensionpause.events; import com.google.inject.Inject; +import org.bukkit.Sound; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.utils.DimensionState; +import org.reprogle.dimensionpause.commands.CommandFeedback; +import org.reprogle.dimensionpause.utils.WorldUtils; public class PlayerJoinEventListener implements Listener { @Inject DimensionState state; + @Inject + ConfigManager configManager; + @Inject + DimensionPausePlugin plugin; + @Inject + CommandFeedback commandFeedback; @EventHandler(priority = EventPriority.HIGHEST) public void onPlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - - if (!state.alertPlayers.contains(player.getUniqueId())) { - return; + World.Environment currentEnv = player.getWorld().getEnvironment(); + World overworld = WorldUtils.getOverworld(player.getWorld()); + World currentWorld = player.getWorld(); + String kickWorld = configManager.getPluginConfig().getString("kick-world"); + + // No need to do anything if the player is already in the world they would be kicked to + if (currentWorld.getName().equals(kickWorld)) return; + + if (!state.getState(overworld, currentEnv).enabled()) { + + // If the player can bypass the environment, quit processing + if (state.canBypass(player, overworld, currentEnv)) return; + + final int delay = configManager.getPluginConfig().getInt("on-join-kick-delay"); + final int[] t = {delay}; + + player.getScheduler().runAtFixedRate(plugin, task -> { + if (!player.isOnline()) { + task.cancel(); + return; + } + + if (t[0] <= 0) { + task.cancel(); + state.kickToWorld(player, currentEnv); + return; + } + + player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.6f, 1.4f); + + player.showTitle(commandFeedback.getTitleForTeleport(t[0], t[0] == delay)); + + t[0]--; + }, null, 20L, 20L); } - - World world = player.getWorld(); - - state.alertPlayer(player, world.getEnvironment()); - state.alertPlayers.remove(player.getUniqueId()); } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerPortalEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerPortalEventListener.java new file mode 100644 index 0000000..7e273a4 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerPortalEventListener.java @@ -0,0 +1,163 @@ +package org.reprogle.dimensionpause.events; + +import com.google.inject.Inject; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.Vector; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.utils.DimensionState; +import org.reprogle.dimensionpause.commands.CommandFeedback; +import org.reprogle.dimensionpause.store.Database; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class PlayerPortalEventListener implements Listener { + + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + @Inject + DimensionState state; + @Inject + DimensionPausePlugin plugin; + + private final Set playersBeingHandled = new HashSet<>(); + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPortalEnter(org.bukkit.event.player.PlayerPortalEvent event) { + // We only care if they're teleporting from the overworld, as exiting the Nether is always okay, and + // End Portals can't be created without commands in the Nether and vice-versa + // The PlayerTeleportEventListener will handle fixing this if the player is somehow teleported to the End from the Nether and vice-versa + if (event.getPlayer().getWorld().getEnvironment() != World.Environment.NORMAL) return; + Player player = event.getPlayer(); + + World.Environment environmentTo = event.getTo().getWorld().getEnvironment(); + String stringifiedEnv = environmentTo == World.Environment.NETHER ? "nether" : "end"; + Database.WorldPauseStatus status = state.getState(player.getWorld(), environmentTo); + if (status.enabled()) return; + if (state.canBypass(player, player.getWorld(), environmentTo)) return; + + event.setCancelled(true); + + // Apply the corresponding "bounce-back" effect depending on the environment (Nether portals are vertical, End Portals are horizontal, so they require different math) + if (configManager.getPluginConfig().getBoolean("dimensions." + stringifiedEnv + ".bounce-back")) { + switch (environmentTo) { + case NETHER -> netherBounceback(player, event.getFrom()); + case THE_END -> endBounceback(player); + default -> { + // Do nothing because we don't care + } + } + } + + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions." + stringifiedEnv + ".alert.title"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions." + stringifiedEnv + ".alert.chat"); + + if (sendTitle) { + player.showTitle(commandFeedback.getTitleForDimension(environmentTo)); + } + + if (sendChat) { + player.sendMessage(commandFeedback.getDimensionIsPausedMessage(environmentTo)); + } + + } + + private void netherBounceback(Player player, Location portalLocation) { + // Ensure this event is not already being handled + if (playersBeingHandled.contains(player.getUniqueId())) { + return; + } + playersBeingHandled.add(player.getUniqueId()); + + // Send the player back a bit to emphasize the pause + // Modified from https://github.com/Multiverse/Multiverse-NetherPortals/blob/7a46c67f0a06064fe7f0e4f7b99aa00afc0c5e25/src/main/java/com/onarandombox/MultiverseNetherPortals/listeners/MVNPEntityListener.java#L77-L127 + double newVecX; + double newVecZ; + double strength = 1.3; + + Block block = portalLocation.getBlock(); + + // determine portal orientation by checking if the block to the west/east is also a nether portal block + if (block.getRelative(BlockFace.WEST).getType() == Material.NETHER_PORTAL || block.getRelative(BlockFace.EAST).getType() == Material.NETHER_PORTAL) { + newVecX = 0; + // we add 0.5 to the location of the block to get the center + if (player.getLocation().getZ() < block.getLocation().getZ() + 0.5) { + // Entered from the North + newVecZ = -1 * strength; + } else { + // Entered from the South + newVecZ = 1 * strength; + } + } else { + newVecZ = 0; + // we add 0.5 to the location of the block to get the center + if (player.getLocation().getX() < block.getLocation().getX() + 0.5) { + // Entered from the West + newVecX = -1 * strength; + } else { + // Entered from the East + newVecX = 1 * strength; + } + } + + player.setVelocity(new Vector(newVecX, .5, newVecZ)); + + // Delay the velocity and removal of the player from the set + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + player.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); + playersBeingHandled.remove(player.getUniqueId()); + }, 1L); // 1 tick or 1/20 of a second + } + + private void endBounceback(Player player) { + // Ensure this event is not already being handled + if (playersBeingHandled.contains(player.getUniqueId())) { + return; + } + + playersBeingHandled.add(player.getUniqueId()); + + float yaw = player.getLocation().getYaw(); + + double radians = Math.toRadians(yaw); + + double x = Math.sin(radians); + double z = -Math.cos(radians); + + Vector knockbackDirection = new Vector(x, 0.7, z); + + knockbackDirection.multiply(0.9); + player.setVelocity(knockbackDirection); + + // Delay the velocity and removal of the player from the set + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + player.addPotionEffect(new PotionEffect(PotionEffectType.RESISTANCE, 200, 5, false, false)); + playersBeingHandled.remove(player.getUniqueId()); + }, 5L); // 1 tick or 1/20 of a second + } + + + // Small event listener to handle cases such as fall damage + @EventHandler + public void onPlayerDamageEvent(EntityDamageEvent event) { + if (!(event.getEntity() instanceof Player p)) return; + + if (playersBeingHandled.contains(p.getUniqueId())) event.setCancelled(true); + } + +} diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java deleted file mode 100644 index ebae2a5..0000000 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.reprogle.dimensionpause.events; - -import com.destroystokyo.paper.profile.PlayerProfile; -import com.google.inject.Inject; -import io.papermc.paper.connection.PlayerConfigurationConnection; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent; -import org.bukkit.event.Listener; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; -import org.reprogle.dimensionpause.DimensionState; - -import java.util.UUID; - -public class PlayerSpawnLocationEventListener implements Listener { - - @Inject - private ConfigManager configManager; - - @Inject - private DimensionState state; - - @Inject - private DimensionPausePlugin plugin; - - // AsyncPlayerSpawnLocationEvent is only available in 1.21+ - @SuppressWarnings("UnstableApiUsage") - @EventHandler(priority = EventPriority.HIGHEST) - public void onPlayerSpawn(AsyncPlayerSpawnLocationEvent event) { - if (event.isNewPlayer()) return; - World world = event.getSpawnLocation().getWorld(); - String kickWorld = configManager.getPluginConfig().getString("kick-world"); - - // No need to do anything if the player is already in the world they would be kicked to - if (world.getName().equals(kickWorld)) { - return; - } - - // Grab the bypassable values for the nether and end. - boolean netherBypass = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - boolean endBypass = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); - - // If the environment the player is teleporting to is disabled, do the following - if (!state.getState(world, world.getEnvironment()).enabled()) { - - // If the player can bypass the environment, quit processing - UUID playerUuid = event.getConnection().getProfile().getId(); - Player player = Bukkit.getPlayer(event.getConnection().getProfile().getId()); - if (playerUuid == null || player == null) { - plugin.getLogger().warning("A player just spawned but their profile could not be retrieved, so we cannot check if they're allowed in this world or not. Check the above logs for the spawn event!"); - return; - } - - if (state.canBypass(player, world.getEnvironment().equals(World.Environment.NETHER) ? netherBypass : endBypass)) - return; - - Location location = state.kickToWorld(player, world.getEnvironment(), false); - - if (location != null) { - event.setSpawnLocation(location); - state.alertPlayers.add(playerUuid); - } - } - } -} diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java index 3f11a7a..b8a5672 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java @@ -1,49 +1,62 @@ package org.reprogle.dimensionpause.events; import com.google.inject.Inject; +import org.bukkit.Location; +import org.bukkit.Particle; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerTeleportEvent; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.utils.DimensionState; +import org.reprogle.dimensionpause.utils.WorldUtils; public class PlayerTeleportEventListener implements Listener { - @Inject - ConfigManager configManager; - @Inject - DimensionState state; - - @EventHandler(priority = EventPriority.HIGHEST) - public void onPlayerTeleport(PlayerTeleportEvent event) { - // If the teleport is localized within the world, ignore the event - if (event.getFrom().getWorld().equals(event.getTo().getWorld())) { - return; - } - // Grab the environment and the player. If the player is teleporting to the overworld, ignore it - World.Environment env = event.getTo().getWorld().getEnvironment(); - Player p = event.getPlayer(); - if (env.equals(World.Environment.NORMAL)) return; - - // Grab the bypassable values for the nether and end. - boolean netherBypass = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - boolean endBypass = configManager.getPluginConfig().getBoolean("dimensions.end.bypassable"); - - // If the environment the player is teleporting to is disabled, do the following - if (!state.getState(event.getTo().getWorld(), env).enabled()) { - - // If the player can bypass the environment, quit processing - if (state.canBypass(p, env.equals(World.Environment.NETHER) ? netherBypass : endBypass)) - return; - - // If the all of the above fail cancel the event - event.setCancelled(true); - - // Send the player the proper title for the environment they tried to access - state.alertPlayer(p, env); - } - } + @Inject + DimensionState state; + @Inject + DimensionPausePlugin plugin; + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerTeleport(PlayerTeleportEvent event) { + // If the teleport is localized within the world, ignore the event + if (event.getFrom().getWorld().equals(event.getTo().getWorld())) { + return; + } + // Grab the environment and the player. If the player is teleporting to the overworld, ignore it + World.Environment env = event.getTo().getWorld().getEnvironment(); + Player p = event.getPlayer(); + if (env.equals(World.Environment.NORMAL)) return; + World fromOverworld = WorldUtils.getOverworld(event.getFrom().getWorld()); + + // If the environment the player is teleporting to is disabled, do the following + if (!state.getState(fromOverworld, env).enabled()) { + + // If the player can bypass the environment, quit processing + if (state.canBypass(p, fromOverworld, env)) + return; + + // If the all of the above fail cancel the event + event.setCancelled(true); + + // Send the player the proper title for the environment they tried to access + state.alertPlayer(p, env); + + // Little smoke effect for when teleport fails + final Location base = event.getPlayer().getLocation().clone().add(0, 0.1, 0); + risingSmoke(p, base, 0, 10); + } + } + + private void risingSmoke(Player p, Location base, int step, int maxSteps) { + if (!p.isOnline() || step > maxSteps) return; + + Location loc = base.clone().add(0, step * 0.15, 0); + loc.getWorld().spawnParticle(Particle.LARGE_SMOKE, loc, 8, 0.2, 0.05, 0.2, 0.01); + + p.getScheduler().runDelayed(plugin, t -> risingSmoke(p, base, step + 1, maxSteps), null, 1L); + } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java index 6f683d8..c341ed6 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java @@ -6,51 +6,52 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.world.PortalCreateEvent; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionState; +import org.reprogle.dimensionpause.utils.ConfigManager; +import org.reprogle.dimensionpause.utils.DimensionState; import org.reprogle.dimensionpause.commands.CommandFeedback; public class PortalCreateEventListener implements Listener { - @Inject - DimensionState state; - @Inject - ConfigManager configManager; - @Inject - CommandFeedback commandFeedback; - - @EventHandler() - public void onPortalCreateEvent(PortalCreateEvent event) { - // We only want to disable the portal creation if a player lights it - if (!(event.getEntity() instanceof Player p)) return; - // We want to NOT block the creation of portals in the Nether, even if the Nether is disabled. Players should always be allowed to escape if necessary - if (p.getWorld().getEnvironment().equals(World.Environment.NETHER)) return; - // If the nether is NOT disabled for this world, ignore the event - if (state.getState(p.getWorld(), World.Environment.NETHER).enabled()) return; - - // We only want to check create reason of FIRE, because the other two, END_PLATFORM, and NETHER_PAIR, should never be cancelled - if (event.getReason().equals(PortalCreateEvent.CreateReason.FIRE)) { - - // Check if the nether is bypassable - boolean bypassable = configManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - - // If the player can bypass the nether, quit processing - if (state.canBypass(p, bypassable)) return; - - // Block portal creation - event.setCancelled(true); - - // Send the player the Nether title and chat messages, if configured - boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.title.enabled"); - boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); - - if (sendTitle) { - p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); - } - - if (sendChat) { - p.sendMessage(commandFeedback.getChatForDimension(World.Environment.NETHER)); - } - } - } + @Inject + DimensionState state; + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + + @EventHandler() + public void onPortalCreateEvent(PortalCreateEvent event) { + // We only want to disable the portal creation if a player lights it + if (!(event.getEntity() instanceof Player p)) return; + // We want to NOT block the creation of portals in the Nether, even if the Nether is disabled. Players should always be allowed to escape if necessary + // In theory, this only matters if a player gets stuck in the Nether even though they don't have bypass permissions + if(p.getWorld().getEnvironment().equals(World.Environment.NETHER)) return; + // If the nether is NOT disabled for this world, ignore the event + if(state.getState(p.getWorld(), World.Environment.NETHER).enabled()) return; + + // We only want to check create reason of FIRE, because the other two, END_PLATFORM, and NETHER_PAIR, should never be canceled + if (event.getReason().equals(PortalCreateEvent.CreateReason.FIRE)) { + + // Check if the nether is paused + if (!state.getState(p.getWorld(), World.Environment.NETHER).enabled()) { + // If the player can bypass the nether, quit processing + if (state.canBypass(p, p.getWorld(), World.Environment.NETHER)) return; + + // Block portal creation + event.setCancelled(true); + + // Send the player the Nether title and chat messages, if configured + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.title"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat"); + + if (sendTitle) { + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); + } + + if (sendChat) { + p.sendMessage(commandFeedback.getDimensionIsPausedMessage(World.Environment.NETHER)); + } + } + } + } } diff --git a/src/main/java/org/reprogle/dimensionpause/events/WorldLoadEvent.java b/src/main/java/org/reprogle/dimensionpause/events/WorldLoadEvent.java new file mode 100644 index 0000000..9f24d2c --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/events/WorldLoadEvent.java @@ -0,0 +1,16 @@ +package org.reprogle.dimensionpause.events; + +import com.google.inject.Inject; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.reprogle.dimensionpause.utils.DimensionExpirationTimer; + +public class WorldLoadEvent implements Listener { + @Inject + DimensionExpirationTimer timer; + + @EventHandler + public void onWorldLoad(WorldLoadEvent event) { + timer.refresh(); + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/store/Database.java b/src/main/java/org/reprogle/dimensionpause/store/Database.java index f9d9b93..7b70aa6 100644 --- a/src/main/java/org/reprogle/dimensionpause/store/Database.java +++ b/src/main/java/org/reprogle/dimensionpause/store/Database.java @@ -4,13 +4,9 @@ import org.reprogle.dimensionpause.DimensionPausePlugin; import javax.annotation.Nullable; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.sql.*; +import java.time.Instant; +import java.util.Arrays; public abstract class Database { private static final String WORLD_TABLE = "dimensionpause_worlds"; @@ -27,42 +23,40 @@ protected Database(DimensionPausePlugin plugin) { public abstract Connection getSQLConnection(); - public void setWorld(String world, World.Environment dimension, boolean enabled, @Nullable LocalDate expiresAt) { - Connection c = null; - PreparedStatement ps = null; - - try { - c = getSQLConnection(); - ps = c.prepareStatement( - INSERT_INTO + WORLD_TABLE + - " (world, dimension, enabled, updatedAt, expiresAt) " + - "VALUES (?, ?, ?, datetime('now'), ?) " + - "ON CONFLICT(world, dimension) DO UPDATE SET " + - "enabled = excluded.enabled, " + - "updatedAt = datetime('now'), " + - "expiresAt = CASE " + - "WHEN excluded.expiresAt IS NOT NULL THEN excluded.expiresAt " + - "WHEN excluded.enabled = 1 AND expiresAt IS NOT NULL AND expiresAt <= datetime('now') THEN NULL " + - "ELSE expiresAt " + - "END" - ); + public WorldPauseStatus setWorld(String world, World.Environment dimension, boolean enabled, @Nullable Instant expiresAt) { + + try (Connection c = getSQLConnection(); PreparedStatement ps = c.prepareStatement( + INSERT_INTO + WORLD_TABLE + + " (world, dimension, enabled, updatedAt, expiresAt) " + + "VALUES (?, ?, ?, datetime('now'), ?) " + + "ON CONFLICT(world, dimension) DO UPDATE SET " + + "enabled = excluded.enabled, " + + "updatedAt = datetime('now'), " + + "expiresAt = CASE " + + " WHEN excluded.expiresAt IS NOT NULL THEN excluded.expiresAt " + + " WHEN excluded.enabled = 1 THEN NULL " + + " ELSE expiresAt " + + "END" + )) { + try { - ps.setString(1, world); - ps.setString(2, dimension.toString()); - ps.setInt(3, enabled ? 1 : 0); - ps.setObject(4, expiresAt); + ps.setString(1, world); + ps.setString(2, dimension.toString()); + ps.setInt(3, enabled ? 1 : 0); - ps.executeUpdate(); - } catch (SQLException e) { - plugin.getLogger().severe("Error while executing create SQL statement on block table: " + e); - } finally { - try { - if (ps != null) ps.close(); - if (c != null) c.close(); + if (expiresAt != null) ps.setLong(4, expiresAt.toEpochMilli()); + else ps.setNull(4, Types.BIGINT); + + ps.executeUpdate(); } catch (SQLException e) { - plugin.getLogger().severe("Failed to close SQL Database connection: " + e); + plugin.getLogger().severe("Error while executing create SQL statement: " + e); + plugin.getLogger().severe(Arrays.toString(e.getStackTrace())); } + } catch (SQLException e) { + plugin.getLogger().severe("Failed to close SQL Database connection: " + e); } + + return new WorldPauseStatus(enabled, expiresAt); } public WorldPauseStatus isWorldEnabled(String world, World.Environment dimension) { @@ -72,32 +66,30 @@ public WorldPauseStatus isWorldEnabled(String world, World.Environment dimension try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { - return new WorldPauseStatus(false, null); + return new WorldPauseStatus(true, null); } boolean enabled = rs.getInt("enabled") == 1; - LocalDateTime expiresAt = null; - String expiresRaw = rs.getString("expiresAt"); - if (expiresRaw != null) { - expiresAt = LocalDateTime.parse(expiresRaw, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - } + Object raw = rs.getObject("expiresAt"); + Long expiresAtMs = (raw instanceof Number n) ? n.longValue() : null; + Instant expiresAt = expiresAtMs != null ? Instant.ofEpochMilli(expiresAtMs) : null; - enabled = enabled || (expiresAt != null && expiresAt.isBefore(LocalDateTime.now())); + enabled = enabled && (expiresAt == null || expiresAt.isBefore(Instant.now())); return new WorldPauseStatus(enabled, expiresAt); } catch (SQLException e) { - plugin.getLogger().severe("Error while executing create SQL statement on block table: " + e); + plugin.getLogger().severe("Error while executing create SQL statement: " + e); } } catch (SQLException e) { plugin.getLogger().severe("Failed to close SQL Database connection: " + e); } - return new WorldPauseStatus(false, null); + return new WorldPauseStatus(true, null); } public record WorldPauseStatus( boolean enabled, - LocalDateTime expiresAt + Instant expiresAt ) { } } diff --git a/src/main/java/org/reprogle/dimensionpause/store/SQLite.java b/src/main/java/org/reprogle/dimensionpause/store/SQLite.java index 9b9f953..ab18ec7 100644 --- a/src/main/java/org/reprogle/dimensionpause/store/SQLite.java +++ b/src/main/java/org/reprogle/dimensionpause/store/SQLite.java @@ -2,7 +2,6 @@ import com.google.inject.Inject; import com.google.inject.Singleton; -import net.kyori.adventure.text.Component; import org.reprogle.dimensionpause.DimensionPausePlugin; import org.reprogle.dimensionpause.store.patches.SQLitePatch; @@ -27,7 +26,7 @@ public class SQLite extends Database { "`dimension` VARCHAR NOT NULL," + "`enabled` INTEGER NOT NULL," + "`updatedAt` DATE NOT NULL," + - "`expiresAt` DATE NULL," + + "`expiresAt` BIGINT NULL," + "PRIMARY KEY (`world`, `dimension`)" + ")"; diff --git a/src/main/java/org/reprogle/dimensionpause/ConfigManager.java b/src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java similarity index 98% rename from src/main/java/org/reprogle/dimensionpause/ConfigManager.java rename to src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java index c88954b..953a7aa 100644 --- a/src/main/java/org/reprogle/dimensionpause/ConfigManager.java +++ b/src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java @@ -1,4 +1,4 @@ -package org.reprogle.dimensionpause; +package org.reprogle.dimensionpause.utils; import com.google.inject.Singleton; import dev.dejvokep.boostedyaml.YamlDocument; diff --git a/src/main/java/org/reprogle/dimensionpause/utils/DimensionExpirationTimer.java b/src/main/java/org/reprogle/dimensionpause/utils/DimensionExpirationTimer.java new file mode 100644 index 0000000..6ead9d6 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/utils/DimensionExpirationTimer.java @@ -0,0 +1,97 @@ +package org.reprogle.dimensionpause.utils; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.store.Database; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +@Singleton +public class DimensionExpirationTimer { + private final Map expirations = new HashMap<>(); + private ScheduledTask nextTask; + + @Inject + DimensionState state; + + @Inject + DimensionPausePlugin plugin; + + public void refresh() { + expirations.clear(); + setExpirations(); + scheduleNext(); + } + + + private void setExpirations() { + Set bases = Bukkit.getWorlds().stream() + .map(WorldUtils::getOverworld) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Instant now = Instant.now(); + + for (World base : bases) { + for (World.Environment env : new World.Environment[]{World.Environment.NETHER, World.Environment.THE_END}) { + Database.WorldPauseStatus status = state.getState(base, env); + Instant exp = status.expiresAt(); + if (exp != null && exp.isAfter(now) && !status.enabled()) { + plugin.getLogger().info("Monitoring world pause expiration for world: " + base.getName() + " " + env); + expirations.put(base.getName() + ":" + env.name(), exp); + } + } + } + } + + private void scheduleNext() { + if (nextTask != null) { + nextTask.cancel(); + nextTask = null; + } + + var next = expirations.entrySet().stream() + .min(Map.Entry.comparingByValue()) + .orElse(null); + + if (next == null) return; + + long ticks = Math.max(1L, Duration.between(Instant.now(), next.getValue()).toSeconds() * 20L); + + nextTask = Bukkit.getGlobalRegionScheduler().runDelayed(plugin, task -> { + expireDue(); + scheduleNext(); + }, ticks); + } + + private void expireDue() { + Instant now = Instant.now(); + + List> due = expirations.entrySet().stream() + .filter(e -> !e.getValue().isAfter(now)) // <= now + .toList(); + + for (var e : due) { + expirations.remove(e.getKey()); + + String[] parts = e.getKey().split(":", 2); + String worldName = parts[0]; + World.Environment env = World.Environment.valueOf(parts[1]); + + World base = Bukkit.getWorld(worldName); + if (base == null) continue; + + plugin.getLogger().info("Expiring pause for for world: " + base.getName() + " " + env); + state.setDimensionState(base, env, DimensionState.State.ENABLED); + } + } + + +} diff --git a/src/main/java/org/reprogle/dimensionpause/utils/DimensionState.java b/src/main/java/org/reprogle/dimensionpause/utils/DimensionState.java new file mode 100644 index 0000000..ebbd4fa --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/utils/DimensionState.java @@ -0,0 +1,224 @@ +package org.reprogle.dimensionpause.utils; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; +import org.reprogle.dimensionpause.DimensionPausePlugin; +import org.reprogle.dimensionpause.commands.CommandFeedback; + +import java.time.Instant; +import java.util.Collection; +import java.util.logging.Level; + +import org.bukkit.Location; +import org.reprogle.dimensionpause.store.Database; +import org.reprogle.dimensionpause.store.SQLite; + +@Singleton +public class DimensionState { + + @Inject + private ConfigManager configManager; + @Inject + private DimensionPausePlugin plugin; + @Inject + private CommandFeedback commandFeedback; + @Inject + private SQLite db; + @Inject + DimensionExpirationTimer timer; + + /** + * A very simple method that toggles a dimensions state, does not allow expiration times + * + * @param world The world to toggle + * @param dimension The dimension in the world to toggle + */ + public void setDimensionState(World world, World.Environment dimension) { + setDimensionState(world, dimension, null, State.TOGGLE); + } + + /** + * Sets a world's dimension with the given state + * + * @param world The world to switch state + * @param dimension The dimension to switch state + * @param state The state to set the dimension to + */ + public void setDimensionState(World world, World.Environment dimension, State state) { + setDimensionState(world, dimension, null, state); + } + + /** + * Toggle a world with a given expiration time. If an expiration time is supplied and not null, the world will be force disabled + * + * @param world The world to toggle + * @param dimension The dimension to toggle for that world + * @param expirationTime The time in which the world is re-enabled + */ + public void setDimensionState(World world, World.Environment dimension, @Nullable Instant expirationTime) { + setDimensionState(world, dimension, expirationTime, expirationTime == null ? State.TOGGLE : State.DISABLED); + } + + /** + * Toggles the dimension for a given world, and kicks players from that world's dimension if there is anyone in the world when it was disabled + * + * @param world The world to toggle + * @param dimension The dimension of the world to toggle + * @param expirationTime An optional expiration date/time which will automatically allow players to re-enter the world once past + */ + public void setDimensionState(World world, World.Environment dimension, @Nullable Instant expirationTime, State state) { + Collection players = plugin.getServer().getOnlinePlayers(); + + String worldName = world.getName(); + + boolean worldDimensionEnabled; + + switch (state) { + case DISABLED -> + worldDimensionEnabled = db.setWorld(worldName, dimension, false, expirationTime).enabled(); + case ENABLED -> + worldDimensionEnabled = db.setWorld(worldName, dimension, true, expirationTime).enabled(); + default -> { + worldDimensionEnabled = db.isWorldEnabled(worldName, dimension).enabled(); + worldDimensionEnabled = db.setWorld(worldName, dimension, !worldDimensionEnabled, expirationTime).enabled(); + } + } + + timer.refresh(); + + alertOfStateChange(players, worldName, dimension, worldDimensionEnabled); + + // Check if the world is now disabled + if (!worldDimensionEnabled) { + for (Player player : plugin.getServer().getOnlinePlayers()) { + if (player.getWorld().getEnvironment().equals(dimension) && !canBypass(player, world, dimension)) + kickToWorld(player, dimension); + } + } + } + + /** + * A helper method to kick a player to a world + * + * @param player The Player being kicked + * @param dimension The dimension the player was kicked FROM + */ + public void kickToWorld(Player player, World.Environment dimension) { + Location loc = player.getRespawnLocation(); + + if (configManager.getPluginConfig().getBoolean("try-bed-first") && loc != null) { + player.teleportAsync(loc); + } else { + World world = Bukkit.getWorld(configManager.getPluginConfig().getString("kick-world")); + + // We can't teleport the player if the kick-world is invalid, so we must return null + if (world == null) { + plugin.getLogger().log(Level.WARNING, "IMPORTANT MESSAGE! A world has been paused, but at least one player is still in it ({0}). This player doesn't have a valid respawn location, and the kick-world configured in config was not obtainable, so we cannot teleport players out of the world. Please intervene!", player.getName()); + return; + } + + // Teleport the player asynchronously (Folia) to the kick-world's spawn + player.teleportAsync(world.getSpawnLocation()); + } + + // Alert the player of the teleport + alertPlayer(player, dimension); + + } + + /** + * Gets the state of a world's dimension for the given world + * + * @param world The world to check + * @param dimension The dimension of the world to check + * @return A {@link Database.WorldPauseStatus} record containing whether the world is enabled, and its expiration time if applicable. + * Will always return false if a world or world's dimension doesn't exist or isn't in the DB + */ + public Database.WorldPauseStatus getState(World world, World.Environment dimension) { + return getState(world.getName(), dimension); + } + + /** + * Gets the state of a world's dimension for the given world + * + * @param world The world name to check + * @param dimension The dimension of the world to check + * @return A {@link Database.WorldPauseStatus} record containing whether the world is enabled, and its expiration time if applicable. + * Will always return false if a world or world's dimension doesn't exist or isn't in the DB + */ + public Database.WorldPauseStatus getState(String world, World.Environment dimension) { + return db.isWorldEnabled(world, dimension); + } + + /** + * Tests if a player can bypass pauses + * + * @param player The player traveling + * @param world The world the player is in. This should always be an overworld + * @param toEnvironment The environment of the world the player would be going to + * @return True if bypassable + */ + public boolean canBypass(Player player, World world, World.Environment toEnvironment) { + if (player.isOp()) return true; + if (player.hasPermission("dimensionpause.*")) return true; + return player.hasPermission("dimensionpause.bypass." + world.getName() + "." + (toEnvironment.equals(World.Environment.NETHER) ? "nether" : "end")); + } + + /** + * Alerts a player of a world's dimension being paused or unpaused via chat and/or title if they attempt to enter + * + * @param player The player to alert + * @param dimension The dimension that was paused (World doesn't matter here) + */ + public void alertPlayer(Player player, World.Environment dimension) { + String env = dimension.equals(World.Environment.NETHER) ? "nether" : "end"; + boolean sendTitle = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.title"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.chat"); + + if (sendTitle) { + player.showTitle(commandFeedback.getTitleForDimension(dimension)); + } + + if (sendChat) { + player.sendMessage(commandFeedback.getDimensionIsPausedMessage(dimension)); + } + } + + /** + * Alerts players via a chat message of a world's dimension being toggled + * + * @param players The player to alert + * @param world The world that was toggled + * @param environment The dimension that was toggled + * @param newState The updated state of the dimension + */ + private void alertOfStateChange(Collection players, String world, World.Environment environment, boolean newState) { + // Get a string value for the dimension. This is useful later on. + String env = environment.equals(World.Environment.NETHER) ? "nether" : "end"; + + if (!configManager.getPluginConfig().getBoolean("dimensions." + env + ".alert.on-toggle")) return; + + for (Player player : players) { + player.sendMessage(commandFeedback.getStateChangedMessage(world, environment, newState)); + } + } + + public enum State { + /** + * Indicates a Dimension that is enabled + */ + ENABLED, + /** + * Indicates a dimension that is disabled + */ + DISABLED, + /** + * Indicates a dimension whose state will be flipped, used in conjunction with the setDimension method + */ + TOGGLE + } +} diff --git a/src/main/java/org/reprogle/dimensionpause/utils/InstantParser.java b/src/main/java/org/reprogle/dimensionpause/utils/InstantParser.java new file mode 100644 index 0000000..b32128b --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/utils/InstantParser.java @@ -0,0 +1,55 @@ +package org.reprogle.dimensionpause.utils; + +import java.time.Instant; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class InstantParser { + private static final Pattern DURATION_PART = + Pattern.compile("(\\d+)([wdhms])", Pattern.CASE_INSENSITIVE); + + private static final List ORDER = List.of('w', 'd', 'h', 'm', 's'); + + public static Instant parseFutureInstant(String input) { + input = input.toLowerCase().trim(); + + Matcher matcher = DURATION_PART.matcher(input); + int lastOrderIndex = -1; + long totalSeconds = 0; + int matchedLength = 0; + + while (matcher.find()) { + long value = Long.parseLong(matcher.group(1)); + char unit = matcher.group(2).charAt(0); + + int orderIndex = ORDER.indexOf(unit); + if (orderIndex == -1) + throw new IllegalArgumentException("Invalid time unit: " + unit); + + if (orderIndex <= lastOrderIndex) + throw new IllegalArgumentException("Invalid duration order"); + + lastOrderIndex = orderIndex; + matchedLength += matcher.group(0).length(); + + totalSeconds += switch (unit) { + case 'w' -> value * 7 * 24 * 60 * 60; + case 'd' -> value * 24 * 60 * 60; + case 'h' -> value * 60 * 60; + case 'm' -> value * 60; + case 's' -> value; + default -> 0; + }; + } + + if (matchedLength != input.length()) + throw new IllegalArgumentException("Invalid duration format"); + + if (totalSeconds <= 0) + throw new IllegalArgumentException("Duration must be greater than zero"); + + return Instant.now().plusSeconds(totalSeconds); + } + +} diff --git a/src/main/java/org/reprogle/dimensionpause/utils/WorldUtils.java b/src/main/java/org/reprogle/dimensionpause/utils/WorldUtils.java new file mode 100644 index 0000000..744bd29 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/utils/WorldUtils.java @@ -0,0 +1,24 @@ +package org.reprogle.dimensionpause.utils; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +public class WorldUtils { + public static World getOverworld(World world) { + World.Environment env = world.getEnvironment(); + + if (env == World.Environment.NORMAL) { + return world; + } + + String name = world.getName(); + + if (env == World.Environment.NETHER && name.endsWith("_nether")) { + name = name.substring(0, name.length() - "_nether".length()); + } else if (env == World.Environment.THE_END && name.endsWith("_the_end")) { + name = name.substring(0, name.length() - "_the_end".length()); + } + + return Bukkit.getWorld(name); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4169a1c..4890496 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,5 +1,5 @@ # This is the config version number. This will auto-increment each time a config update is done. DO NOT TOUCH THIS!!!!! -file-version: 3 +file-version: 4 ###################################################################### # F O R M A T T I N G N O T E S # @@ -14,59 +14,41 @@ file-version: 3 # P L U G I N S E T T I N G S # ###################################################################### -# A list of dimensions. You may set these to be disabled or not, and also set them to be bypassable -# If a dimension is not bypassable, only OPs may enter it, players with the bypass permission may not. -# NOTE: The above also includes players with "*" permissions, such as server owners! You MUST be OP to bypass a non-bypassable world! +# Configuration for the different dimension types. These settings apply to all dimensions regardless of world +# Dimension pauses may be bypassed by granting the permission `dimensionpause.bypass.[world].[dimension]` +# For example, to let players in to the `world` world's `nether` dimension, grant `dimensionpause.bypass.world.nether` to them dimensions: end: - paused: false - bypassable: true # Applies a bit of knockback velocity when a player tries to teleport bounce-back: true alert: # Allows you to alert players if they attempt to teleport to disabled worlds - title: - # Set if a title will be displayed if a player attempts to enter a disabled world - enabled: true - title: "Sorry, The End is currently paused!" - subtitle: "As a result, you may not enter The End or activate End Portals" - chat: - # Set if a chat message will be sent to the player if they attempt to enter a disabled world - enabled: false - message: "YOU SHALL NOT PASS" - on-toggle: - # Alert players when the dimension is toggled. This will always be a chat message. Use %state% to display the current state of the dimension. - enabled: true - message: "Attention! The End has been %state%!" + # To change the messages used, edit the language file + title: true + chat: false + on-toggle: true nether: - paused: false - bypassable: true # Applies a bit of knockback velocity when a player tries to teleport bounce-back: true alert: # Allows you to alert players if they attempt to teleport to disabled worlds - title: - # Set if a title will be displayed if a player attempts to enter a disabled world - enabled: true - title: "Sorry, The Nether is currently paused!" - subtitle: "As a result, you may not enter The Nether or activate Nether Portals" - chat: - # Set if a chat message will be sent to the player if they attempt to enter a disabled world - enabled: false - message: "YOU SHALL NOT PASS" - on-toggle: - # Alert players when the dimension is toggled. This will always be a chat message. Use %state% to display the current state of the dimension. - enabled: true - message: "Attention! The Nether has been %state%!" + # To change the messages used, edit the language file + title: true + chat: false + on-toggle: true # The name of the world you want to kick players to if they are currently in a dimension when it's paused. -# If a player attempts to teleport to a paused dimension, they'll be teleported back to their previous world's spawn point. This value is ignored in that scenario +# If a player attempts to teleport to a paused dimension, the teleport will be blocked. This value is ignored in that scenario kick-world: "world" # If we should try to teleport them to their bed first try-bed-first: true +# If a player joins the server and spawned in a disabled world, a countdown will start for the player +# This is how many SECONDS the countdown will be before the player is teleported out +on-join-kick-delay: 5 + ###################################################################### # C H A T S E T T I N G S # ###################################################################### diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index fc58198..e353884 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -1,20 +1,58 @@ # This is the version of the language file. If new translations are added, this will automatically update. DO NOT TOUCH THIS!!!!! language-version: 4 -# Language configs for this plugin use MiniMessage. If you need to know how to use MiniMessage, click the link below! -# https://docs.advntr.dev/minimessage/format.html#standard-tags +# Language configs for this plugin use MiniMessage. If you need to know how to use MiniMessage, click this link: https://docs.advntr.dev/minimessage/format.html#standard-tags +# The below messages support three types of MiniMessage placeholders: +# - The world which owns the dimension +# - The state of the world, or what it was changed to +# - The prefix for the chat message +# Not all messages support the , , or type, and these tags are not supported on Titles whatsoever +# Refer to the comments above each section for formatting guidelines +# Using the and/or tag in messages that don't support them WILL yield unexpected results + prefix: "[DimensionPause]" -unknown-error: "An error occurred while performing that command" -reload: "Dimension Pause config has been reloaded!" -io-exception: "An error occurred while saving the config file. Dimension state changes in-game will still work, but they may be reset upon reload or restart." -no-permission: "Sorry, you don't have permission to run this command." +unknown-error: " An error occurred while performing that command" +reload: " Dimension Pause config has been reloaded!" +io-exception: " An error occurred while saving changes. If not resolved, dimension state changes cannot be made, and paused dimensions cannot be unpaused" +no-permission: " Sorry, you don't have permission to run this command." + +# Toggles are used when a world/dimension state is changed (Such as with /dp toggle). These are only sent to whoever changed the state, such as Console or a staff member. +# Automated changes, such as if the world was temporarily paused, do not generate toggle messages +# Supported placeholders: , , (World and state are not supported on the default message) toggled: - nether: "The Nether has been " - end: "The End has been " - default: "The dimension has been toggled!" + nether: " The Nether has been for world " + end: " The End has been " + default: " The dimension has been toggled!" + +# States are used when the state of a world/dimension is checked (Such as with /dp state) +# state.paused and state.unpaused are also special in that these are the values that will be used whenever is used +# state.nether and state.end support the , , and tags. Please do not use any tags on the other messages state: - nether: "The Nether is currently " - end: "The End is currently " - paused: "paused " - unpaused: "unpaused " - until: "until " \ No newline at end of file + nether: " The Nether is currently " + end: " The End is currently " + paused: "paused" + unpaused: "unpaused" + until: "until" + +# Alerts are used to tell players that a dimension is paused when it's toggled or when they attempt to enter it +# alert.[dimension].chat and alert.[dimension].on-toggle support , , and tags +alert: + nether: + title: + title: "Sorry, The Nether is currently paused!" + subtitle: "As a result, you may not enter The Nether or activate Nether Portals" + chat: " YOU SHALL NOT PASS" + on-toggle: " Attention! The Nether has been !" + end: + title: + title: "Sorry, The End is currently paused!" + subtitle: "As a result, you may not enter The End or activate End Portals" + chat: " YOU SHALL NOT PASS" + on-toggle: " Attention! The End has been !" + +# Preteleport is used when a player joins a paused world, and we must teleport them out. These messages are given to the player prior to teleporting. This only supports titles +# This section does not support tags at all +preteleport: + title: "This dimension is paused!" + # Subtitle will have the number of seconds appended to the end of it + subtitle: "Teleporting away in" \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e033750..2b58b28 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -21,9 +21,6 @@ permissions: dimensionpause.toggle: description: Allows a player to change the state of a dimension default: op - dimensionpause.bypass: - description: Allows a player to bypass - default: op dimensionpause.reload: description: Reloads all configuration files, including translation files default: op @@ -35,7 +32,6 @@ permissions: default: op children: dimensionpause.commands: true - dimensionpause.bypass: true dimensionpause.toggle: true dimensionpause.reload: true dimensionpause.state: true \ No newline at end of file From 93d1fecb58934e12c8d1b8d823d2a3584e810594 Mon Sep 17 00:00:00 2001 From: Nate Reprogle Date: Sun, 18 Jan 2026 19:54:53 -0600 Subject: [PATCH 7/8] Update build scripts with correct supported versions of Paper --- .github/workflows/publish-to-hangar.sh | 2 +- .github/workflows/publish-to-modrinth.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-to-hangar.sh b/.github/workflows/publish-to-hangar.sh index bb692bc..2d830fa 100644 --- a/.github/workflows/publish-to-hangar.sh +++ b/.github/workflows/publish-to-hangar.sh @@ -60,7 +60,7 @@ cat > versionUpload.json < metadata.json < Date: Sun, 18 Jan 2026 19:56:48 -0600 Subject: [PATCH 8/8] Update verison text file --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 45a1b3f..359a5b9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.2 +2.0.0 \ No newline at end of file