diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6f72e70 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,95 @@ +name: Build and Publish +on: + push: + branches-ignore: + - main + release: + types: [ published ] + +jobs: + build: + runs-on: ubuntu-latest + if: github.repository_owner == 'TerrorByteTW' + 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: "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 + ./.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: "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 + ./.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..2d830fa --- /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..c03f1f4 --- /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 @@ -13,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. + +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. -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. +## 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 new file mode 100644 index 0000000..1850a0f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,82 @@ +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" + alias(libs.plugins.lombok); +} + +project.group = "org.reprogle" +project.version = "2.0.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(libs.paper.api) +// compileOnly(libs.folia.api) + compileOnly(libs.boosted.yaml) + implementation(libs.bstats) + compileOnly(libs.guice) +} + +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("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..29ace73 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +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] +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.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..19a6bde --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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..cc170dc --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/DPMetrics.java @@ -0,0 +1,19 @@ +package org.reprogle.dimensionpause; + +import org.bstats.bukkit.Metrics; +import org.bukkit.plugin.java.JavaPlugin; + +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/DimensionPauseModule.java b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java new file mode 100644 index 0000000..e1f7710 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPauseModule.java @@ -0,0 +1,43 @@ +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; +import org.reprogle.dimensionpause.utils.ConfigManager; + +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 ad2a1e4..375f9fc 100644 --- a/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java +++ b/src/main/java/org/reprogle/dimensionpause/DimensionPausePlugin.java @@ -1,55 +1,73 @@ package org.reprogle.dimensionpause; +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.bstats.bukkit.Metrics; +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 { - 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"); - } + @Inject + ListenerManager listenerManager; + @Inject + CommandManager commandManager; + @Inject + DimensionExpirationTimer timer; + + @Getter + 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() { + new DPMetrics(this); + + getCommand("dimensionpause").setExecutor(this.commandManager); + listenerManager.setupListeners(); + + getLogger().info("Dimension Pause has been loaded"); + + if (this.getDescription().getVersion().contains("SNAPSHOT")) { + 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(".", ""))) { + 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 { + getServer().getConsoleSender().sendMessage(Component.text("You are on the latest version of DimensionPause!", NamedTextColor.GREEN)); + } + }); + } + + 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!")); + } + + timer.refresh(); + } + + @Override + public void onDisable() { + getLogger().info("Dimension Pause is shutting down"); + } + + 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 ab1d09c..0000000 --- a/src/main/java/org/reprogle/dimensionpause/DimensionState.java +++ /dev/null @@ -1,136 +0,0 @@ -package org.reprogle.dimensionpause; - -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.util.Collection; -import java.util.logging.Level; - -import org.bukkit.Location; - -public class DimensionState { - - // 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 loc; - - if (ConfigManager.getPluginConfig().getBoolean("try-bed-first") && player.getBedSpawnLocation() != null) { - if (teleport) player.teleport(player.getBedLocation()); - 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) { - // 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)); - } - } - - 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"); - } - - 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)); - } - - } - -} diff --git a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java index a021334..9091ee5 100644 --- a/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java +++ b/src/main/java/org/reprogle/dimensionpause/UpdateChecker.java @@ -2,31 +2,50 @@ import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; -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; +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 + 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..72abe8e 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.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.title.Title; import org.bukkit.World; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; +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.util.Objects; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; 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.of(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; + + /** + * 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 World world, @Nullable String dimension) { + Component feedbackMessage; + 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" -> { + 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" -> { + 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(deserialize(languageFile.getString("toggled." + dimension), worldState.enabled(), world.getName())); + + // 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")); + } + + feedbackMessage = builder.build(); + } + } + case "state" -> { + if (environment == null || world == null) return Component.empty(); + + Database.WorldPauseStatus worldState = state.getState(world, environment); + 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 = deserialize(languageFile.getString("unknown-error"), false, null); + } + + 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.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 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 deserialize(configManager.getLanguageFile().getString("alert." + environment + ".chat"), false, null); + } + + public Component getStateChangedMessage(World world, World.Environment env, boolean enabled) { + return getStateChangedMessage(world.getName(), env, enabled); + } + + public Component getStateChangedMessage(String world, World.Environment env, boolean enabled) { + String environment = env.equals(World.Environment.NETHER) ? "nether" : "end"; + 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/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..6aaaf08 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Reload.java @@ -1,15 +1,24 @@ 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.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; import java.util.List; public class Reload implements SubCommand { + @Inject + ConfigManager configManager; + @Inject + CommandFeedback commandFeedback; + @Inject + DimensionExpirationTimer timer; + @Override public String getName() { return "reload"; @@ -18,13 +27,15 @@ 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(); + timer.refresh(); - 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..7b6c545 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,58 @@ 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) { + 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 { + 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..1860f45 100644 --- a/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java +++ b/src/main/java/org/reprogle/dimensionpause/commands/subcommands/Toggle.java @@ -1,54 +1,85 @@ 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.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; 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"))) { + 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.setDimensionState(world, environment, pauseExpiration); + 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 deleted file mode 100644 index f2f3795..0000000 --- a/src/main/java/org/reprogle/dimensionpause/events/EntityPortalEnterEventListener.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.reprogle.dimensionpause.events; - -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.commands.CommandFeedback; - -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -public class EntityPortalEnterEventListener implements Listener { - - 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)) { - 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 (DimensionPausePlugin.ds.canBypass(p, ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"))) { - return; - } - - // 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 - DimensionPausePlugin.plugin.getServer().getScheduler().runTaskLater(DimensionPausePlugin.plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.DAMAGE_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") || !DimensionPausePlugin.ds.getState(World.Environment.THE_END)) { - 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 (DimensionPausePlugin.ds.canBypass(p, ConfigManager.getPluginConfig().getBoolean("dimensions.end.bypassable"))) { - return; - } - - // 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 - DimensionPausePlugin.plugin.getServer().getScheduler().runTaskLater(DimensionPausePlugin.plugin, () -> { - p.addPotionEffect(new PotionEffect(PotionEffectType.DAMAGE_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.DAMAGE_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 e6bda9a..b17716c 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java +++ b/src/main/java/org/reprogle/dimensionpause/events/ListenerManager.java @@ -1,20 +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.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 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 + PlayerJoinEventListener playerJoinEventListener; + @Inject + PlayerTeleportEventListener playerTeleportEventListener; + @Inject + PlayerInteractEventListener playerInteractEventListener; + @Inject + PortalCreateEventListener portalCreateEventListener; + @Inject + PlayerPortalEventListener playerPortalEventListener; + + @Inject + ListenerManager(DimensionPausePlugin plugin) { + this.plugin = plugin; + } + + /** + * Set's up all the listeners in the entire plugin + */ + public void setupListeners() { + 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 2b8e10a..84b1c3d 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; @@ -7,35 +8,41 @@ 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.utils.ConfigManager; +import org.reprogle.dimensionpause.utils.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) return; + if (event.getClickedBlock() == null) return; + if (!event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) return; + if (event.getMaterial() != Material.ENDER_EYE) return; + + World world = event.getPlayer().getWorld(); + if (state.getState(world, World.Environment.THE_END).enabled()) return; + + 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"); + boolean sendChat = configManager.getPluginConfig().getBoolean("dimensions.end.alert.chat"); + + if (sendTitle) { + p.showTitle(commandFeedback.getTitleForDimension(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 new file mode 100644 index 0000000..2063439 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerJoinEventListener.java @@ -0,0 +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.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(); + 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); + } + } + +} 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 712cfba..0000000 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerSpawnLocationEventListener.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.reprogle.dimensionpause.events; - -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.reprogle.dimensionpause.ConfigManager; -import org.reprogle.dimensionpause.DimensionPausePlugin; -import org.spigotmc.event.player.PlayerSpawnLocationEvent; - -public class PlayerSpawnLocationEventListener implements Listener{ - - @EventHandler(priority = EventPriority.HIGHEST) - public static void onPlayerSpawn(PlayerSpawnLocationEvent event) { - 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 (DimensionPausePlugin.ds.getState(world.getEnvironment())) { - - // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.canBypass(event.getPlayer(), 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); - - if (location != null) { - event.setSpawnLocation(location); - } - } - } -} diff --git a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java index 2257062..b8a5672 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PlayerTeleportEventListener.java @@ -1,55 +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.DimensionPausePlugin; -import org.reprogle.dimensionpause.commands.CommandFeedback; +import org.reprogle.dimensionpause.utils.DimensionState; +import org.reprogle.dimensionpause.utils.WorldUtils; public class PlayerTeleportEventListener implements Listener { - - @EventHandler(priority = EventPriority.HIGHEST) - public static 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 (DimensionPausePlugin.ds.getState(env)) { - - // If the player can bypass the environment, quit processing - if (DimensionPausePlugin.ds.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 - 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)); - } - } - } + @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 99fde4c..c341ed6 100644 --- a/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java +++ b/src/main/java/org/reprogle/dimensionpause/events/PortalCreateEventListener.java @@ -1,46 +1,55 @@ 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.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 static void onPortalCreateEvent(PortalCreateEvent event) { + 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; - if(!DimensionPausePlugin.ds.getState(World.Environment.NETHER)) 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 cancelled + // 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 bypassable - boolean bypassable = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.bypassable"); - // Check if the nether is paused - if (DimensionPausePlugin.ds.getState(World.Environment.NETHER)) { + if (!state.getState(p.getWorld(), World.Environment.NETHER).enabled()) { // If the player can bypass the nether, quit processing - if (DimensionPausePlugin.ds.canBypass(p, bypassable)) return; + 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.enabled"); - boolean sendChat = ConfigManager.getPluginConfig().getBoolean("dimensions.nether.alert.chat.enabled"); + 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)); + p.showTitle(commandFeedback.getTitleForDimension(World.Environment.NETHER)); } if (sendChat) { - p.sendMessage(CommandFeedback.getChatForDimension(World.Environment.NETHER)); + 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 new file mode 100644 index 0000000..7b70aa6 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/store/Database.java @@ -0,0 +1,95 @@ +package org.reprogle.dimensionpause.store; + +import org.bukkit.World; +import org.reprogle.dimensionpause.DimensionPausePlugin; + +import javax.annotation.Nullable; +import java.sql.*; +import java.time.Instant; +import java.util.Arrays; + +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 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); + + if (expiresAt != null) ps.setLong(4, expiresAt.toEpochMilli()); + else ps.setNull(4, Types.BIGINT); + + ps.executeUpdate(); + } catch (SQLException 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) { + 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(true, null); + } + + boolean enabled = rs.getInt("enabled") == 1; + 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(Instant.now())); + + return new WorldPauseStatus(enabled, expiresAt); + } catch (SQLException 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(true, null); + } + + public record WorldPauseStatus( + boolean enabled, + Instant 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..ab18ec7 --- /dev/null +++ b/src/main/java/org/reprogle/dimensionpause/store/SQLite.java @@ -0,0 +1,142 @@ +package org.reprogle.dimensionpause.store; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +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` BIGINT 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/java/org/reprogle/dimensionpause/ConfigManager.java b/src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java similarity index 94% rename from src/main/java/org/reprogle/dimensionpause/ConfigManager.java rename to src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java index 18e4e59..953a7aa 100644 --- a/src/main/java/org/reprogle/dimensionpause/ConfigManager.java +++ b/src/main/java/org/reprogle/dimensionpause/utils/ConfigManager.java @@ -1,5 +1,6 @@ -package org.reprogle.dimensionpause; +package org.reprogle.dimensionpause.utils; +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/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 da1526c..e353884 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -1,17 +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: 3 +language-version: 4 + +# 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 -# 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 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 " \ 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 29a53d4..2b58b28 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,14 +1,18 @@ name: DimensionPause -version: '${project.version}' +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: @@ -17,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 @@ -31,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 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