Update files.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id("xyz.jpenilla.run-paper") version "2.3.1"
|
||||
id 'com.gradleup.shadow' version '8.3.6'
|
||||
}
|
||||
|
||||
group = 'net.wei'
|
||||
version = '1.0-dev'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = "papermc-repo"
|
||||
url = "https://repo.papermc.io/repository/maven-public/"
|
||||
}
|
||||
maven {
|
||||
name = "placeholderapi"
|
||||
url = 'https://repo.extendedclip.com/releases/'
|
||||
}
|
||||
maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' }
|
||||
maven {
|
||||
url "https://central.sonatype.com/repository/maven-snapshots/"
|
||||
name "sonatype-snapshots"
|
||||
mavenContent {
|
||||
snapshotsOnly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("me.clip:placeholderapi:2.12.2")
|
||||
implementation('de.exlll:configlib-paper:4.5.0')
|
||||
implementation('de.exlll:configlib-yaml:4.5.0')
|
||||
implementation('de.exlll:configlib-core:4.5.0')
|
||||
implementation('com.j256.ormlite:ormlite-jdbc:6.1')
|
||||
implementation("org.xerial:sqlite-jdbc:3.45.1.0")
|
||||
implementation('org.incendo:cloud-core:2.0.0')
|
||||
implementation("org.incendo:cloud-paper:2.0.0-beta.15")
|
||||
implementation('org.incendo:cloud-minecraft-extras:2.0.0-beta.10')
|
||||
implementation('com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.16.2')
|
||||
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
|
||||
}
|
||||
|
||||
|
||||
tasks {
|
||||
|
||||
shadowJar {
|
||||
archiveClassifier.set("")
|
||||
}
|
||||
|
||||
build {
|
||||
dependsOn shadowJar
|
||||
}
|
||||
|
||||
runServer {
|
||||
dependsOn shadowJar
|
||||
// Configure the Minecraft version for our task.
|
||||
// This is the only required configuration besides applying the plugin.
|
||||
// Your plugin's jar (or shadowJar if present) will be used automatically.
|
||||
minecraftVersion("1.21.11")
|
||||
}
|
||||
}
|
||||
|
||||
def targetJavaVersion = 21
|
||||
java {
|
||||
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
|
||||
sourceCompatibility = javaVersion
|
||||
targetCompatibility = javaVersion
|
||||
if (JavaVersion.current() < javaVersion) {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
|
||||
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
|
||||
options.release.set(targetJavaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
processResources {
|
||||
def props = [version: version]
|
||||
inputs.properties props
|
||||
filteringCharset 'UTF-8'
|
||||
filesMatching('plugin.yml') {
|
||||
expand props
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/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.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
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, 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" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# 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" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@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
|
||||
|
||||
@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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
: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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'Toozlie'
|
||||
@@ -0,0 +1,540 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.io.File;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
public class ActionHelper {
|
||||
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger("Toozlie.ActionHelper");
|
||||
|
||||
// Action file
|
||||
private File actionFile;
|
||||
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
private BukkitTask actionTask;
|
||||
|
||||
public static final List<String> SupportActions = List.of("action.server.stop",
|
||||
"action.server.reload", "action.item.clear");
|
||||
|
||||
public static class Action {
|
||||
public String name;
|
||||
public Timestamp addTime;
|
||||
public Timestamp latestExecuteTime;
|
||||
public boolean doOnce = true;
|
||||
public int executeIntervalSec = 600;
|
||||
public boolean displayRemainingTime = false;
|
||||
public boolean displayStartMessage;
|
||||
public boolean displayAtActionBar = false;
|
||||
public String targetActionName = "";
|
||||
public long latestCountdownRemainSec = -1;
|
||||
|
||||
public Action() {
|
||||
}
|
||||
|
||||
public Action(String name, Timestamp addTime, Timestamp latestExecuteTime, boolean doOnce, boolean displayStartMessage,
|
||||
int executeIntervalSec, boolean displayRemainingTime, boolean displayAtActionBar, String targetActionName) {
|
||||
this.name = name;
|
||||
this.addTime = addTime;
|
||||
this.latestExecuteTime = latestExecuteTime;
|
||||
this.doOnce = doOnce;
|
||||
this.displayStartMessage = displayStartMessage;
|
||||
this.executeIntervalSec = executeIntervalSec;
|
||||
this.displayRemainingTime = displayRemainingTime;
|
||||
this.displayAtActionBar = displayAtActionBar;
|
||||
if (!SupportActions.contains(targetActionName)) {
|
||||
throw new IllegalArgumentException("Unsupported action name: "+targetActionName);
|
||||
} else {
|
||||
this.targetActionName = targetActionName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Actions {
|
||||
private List<Action> actions = new ArrayList<>();
|
||||
|
||||
public Actions() {
|
||||
}
|
||||
|
||||
// Getter & Setter
|
||||
public List<Action> getActions() { return actions; }
|
||||
public void setActions(List<Action> actions) {
|
||||
this.actions = actions == null ? new ArrayList<>() : actions;
|
||||
}
|
||||
}
|
||||
private final Toozlie toozlie;
|
||||
public Actions actions;
|
||||
|
||||
public ActionHelper(Toozlie toozlie) {
|
||||
this.toozlie = toozlie;
|
||||
}
|
||||
|
||||
private void loadAllActionsFromDisk() {
|
||||
try {
|
||||
actionFile = new File(toozlie.getDataFolder(), "actions.yml");
|
||||
|
||||
if (!toozlie.getDataFolder().exists() && !toozlie.getDataFolder().mkdirs()) {
|
||||
logger.warn("Failed to create plugin data folder: {}", toozlie.getDataFolder());
|
||||
}
|
||||
|
||||
if (!actionFile.exists() || actionFile.length() == 0) {
|
||||
actions = new ActionHelper.Actions();
|
||||
saveAllactions();
|
||||
return;
|
||||
}
|
||||
|
||||
actions = yamlMapper.readValue(actionFile, ActionHelper.Actions.class);
|
||||
if (actions == null) {
|
||||
actions = new ActionHelper.Actions();
|
||||
}
|
||||
if (actions.getActions() == null) {
|
||||
actions.setActions(new ArrayList<>());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
logger.error("An error occurred while reading broadcast file. Exec: {}", exception.toString());
|
||||
actions = new ActionHelper.Actions();
|
||||
}
|
||||
}
|
||||
|
||||
private Toozlie.Result<Boolean> saveAllactions() {
|
||||
try {
|
||||
if (actionFile == null) {
|
||||
actionFile = new File(toozlie.getDataFolder(), "actions.yml");
|
||||
}
|
||||
yamlMapper.writeValue(actionFile, actions);
|
||||
return Toozlie.Result.success(true);
|
||||
} catch (Exception exception) {
|
||||
logger.error("An error occurred while saving broadcast file. Exec: {}", exception.toString());
|
||||
return Toozlie.Result.error("Failed to save broadcast file.");
|
||||
}
|
||||
}
|
||||
|
||||
public void addAction(String name, Timestamp latestExecuteTime, boolean doOnce, boolean displayRemainingTime,
|
||||
int executeIntervalSec, boolean displayAtActionBar, String targetActionName) {
|
||||
ensureActionsLoaded();
|
||||
Action action = new Action(name, Timestamp.from(Instant.now()), latestExecuteTime, doOnce, displayRemainingTime,
|
||||
executeIntervalSec, displayRemainingTime, displayAtActionBar, targetActionName);
|
||||
actions.getActions().add(action);
|
||||
saveAllactions();
|
||||
}
|
||||
|
||||
public void startActionScheduler() {
|
||||
loadAllActionsFromDisk();
|
||||
|
||||
if (actionTask != null) {
|
||||
actionTask.cancel();
|
||||
}
|
||||
|
||||
actionTask = Bukkit.getScheduler().runTaskTimer(toozlie, () -> {
|
||||
ensureActionsLoaded();
|
||||
|
||||
Timestamp now = Timestamp.from(Instant.now());
|
||||
boolean changed = false;
|
||||
Iterator<Action> iterator = actions.getActions().iterator();
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
Action action = iterator.next();
|
||||
if (action == null) {
|
||||
iterator.remove();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (normalizeAction(action)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (broadcastCountdownIfNeeded(action, now)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!isDue(action, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.displayRemainingTime && !"action.item.clear".equals(action.targetActionName)) {
|
||||
broadcastStartMessage(action);
|
||||
}
|
||||
|
||||
executeAction(action);
|
||||
changed = true;
|
||||
|
||||
if (action.doOnce) {
|
||||
iterator.remove();
|
||||
} else {
|
||||
action.latestExecuteTime = Timestamp.from(now.toInstant().plusSeconds(effectiveExecuteIntervalSec(action)));
|
||||
action.latestCountdownRemainSec = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
saveAllactions();
|
||||
}
|
||||
}, 20L, 20L);
|
||||
}
|
||||
|
||||
public void stopActionScheduler() {
|
||||
if (actionTask != null) {
|
||||
actionTask.cancel();
|
||||
actionTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Toozlie.Result<Boolean> removeAction(int index) {
|
||||
ensureActionsLoaded();
|
||||
if (actions.getActions().isEmpty()) {
|
||||
return Toozlie.Result.error("Action list are empty.");
|
||||
}
|
||||
|
||||
if (index < 0 || actions.getActions().size() < index+1) {
|
||||
return Toozlie.Result.error("Action index out of range.");
|
||||
}
|
||||
|
||||
actions.getActions().remove(index);
|
||||
|
||||
return saveAllactions();
|
||||
}
|
||||
|
||||
private static final int PAGE_SIZE = 5;
|
||||
private static final DateTimeFormatter TS_FMT = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
public void addActionOnCommand(CommandSender sender, String name, String action,
|
||||
int executeIntervalMinute, boolean doOnce,
|
||||
boolean displayRemainingTime, boolean displayAtActionBar) {
|
||||
if (!ActionHelper.SupportActions.contains(action)) {
|
||||
sender.sendMessage(toozlie.messages.get(sender, "action.unsupported", "action", action));
|
||||
return;
|
||||
}
|
||||
|
||||
if (executeIntervalMinute < 0) {
|
||||
sender.sendMessage(toozlie.messages.get(sender, "action.invalid-time"));
|
||||
return;
|
||||
}
|
||||
|
||||
int minimumIntervalMinute = minimumExecuteIntervalMinute(action);
|
||||
if (!doOnce && executeIntervalMinute < minimumIntervalMinute) {
|
||||
sender.sendMessage(toozlie.messages.get(
|
||||
sender,
|
||||
"action.invalid-interval",
|
||||
"minutes",
|
||||
String.valueOf(minimumIntervalMinute)
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
startActionScheduler();
|
||||
|
||||
Instant executeAt = Instant.now().plus(executeIntervalMinute, ChronoUnit.MINUTES);
|
||||
Timestamp actionTimestamp = Timestamp.from(executeAt);
|
||||
int executeIntervalSec = Math.max(1, executeIntervalMinute * 60);
|
||||
addAction(
|
||||
name,
|
||||
actionTimestamp,
|
||||
doOnce,
|
||||
displayRemainingTime,
|
||||
executeIntervalSec,
|
||||
displayAtActionBar,
|
||||
action
|
||||
);
|
||||
sender.sendMessage(toozlie.messages.get(
|
||||
sender,
|
||||
"action.scheduled",
|
||||
"action",
|
||||
action,
|
||||
"minutes",
|
||||
String.valueOf(executeIntervalMinute),
|
||||
"mode",
|
||||
doOnce ? "once" : "repeat",
|
||||
"displayRemainingTime",
|
||||
String.valueOf(displayRemainingTime)
|
||||
));
|
||||
}
|
||||
|
||||
public boolean list(CommandSender sender, int page) {
|
||||
ensureActionsLoaded();
|
||||
|
||||
if (actions.getActions().isEmpty()) {
|
||||
sender.sendMessage(toozlie.messages.get(sender, "action.none"));
|
||||
return true;
|
||||
}
|
||||
|
||||
int total = actions.getActions().size();
|
||||
int maxPage = Math.max(1, (int) Math.ceil(total / (double) PAGE_SIZE));
|
||||
page = Math.min(Math.max(page, 1), maxPage);
|
||||
|
||||
int start = (page - 1) * PAGE_SIZE;
|
||||
int end = Math.min(start + PAGE_SIZE, total);
|
||||
|
||||
sender.sendMessage(toozlie.messages.get(
|
||||
sender,
|
||||
"action.header",
|
||||
"page",
|
||||
String.valueOf(page),
|
||||
"maxPage",
|
||||
String.valueOf(maxPage),
|
||||
"total",
|
||||
String.valueOf(total)
|
||||
));
|
||||
|
||||
Timestamp now = Timestamp.from(Instant.now());
|
||||
for (int i = start; i < end; i++) {
|
||||
sendOne(sender, actions.getActions().get(i), i + 1, now);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void listSupport(CommandSender sender) {
|
||||
sender.sendMessage(toozlie.messages.get(
|
||||
sender,
|
||||
"action.support.header",
|
||||
"total",
|
||||
String.valueOf(SupportActions.size())
|
||||
));
|
||||
|
||||
for (String action : SupportActions) {
|
||||
sender.sendMessage(Toozlie.deserializeText(toozlie.messages.get(
|
||||
sender,
|
||||
"action.support.entry",
|
||||
"action",
|
||||
action
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
private void sendOne(CommandSender sender, Action action, int index, Timestamp now) {
|
||||
String message = toozlie.messages.get(
|
||||
sender,
|
||||
"action.entry",
|
||||
"index",
|
||||
String.valueOf(index),
|
||||
"name",
|
||||
nullToEmpty(action.name),
|
||||
"action",
|
||||
nullToEmpty(action.targetActionName),
|
||||
"mode",
|
||||
action.doOnce ? "once" : "repeat",
|
||||
"nextTime",
|
||||
formatNextExecuteTime(action, now),
|
||||
"interval",
|
||||
formatInterval(action.executeIntervalSec),
|
||||
"displayRemainingTime",
|
||||
String.valueOf(action.displayRemainingTime)
|
||||
);
|
||||
Component line = Toozlie.deserializeText(message);
|
||||
sender.sendMessage(line);
|
||||
}
|
||||
|
||||
private String formatNextExecuteTime(Action action, Timestamp now) {
|
||||
Instant next = nextExecuteTime(action, now);
|
||||
if (next == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (!next.isAfter(now.toInstant())) {
|
||||
return "due";
|
||||
}
|
||||
return TS_FMT.format(next);
|
||||
}
|
||||
|
||||
private Instant nextExecuteTime(Action action, Timestamp now) {
|
||||
if (action.latestExecuteTime == null) {
|
||||
return action.addTime == null ? now.toInstant() : action.addTime.toInstant();
|
||||
}
|
||||
|
||||
return action.latestExecuteTime.toInstant();
|
||||
}
|
||||
|
||||
private void ensureActionsLoaded() {
|
||||
if (actionFile == null) {
|
||||
loadAllActionsFromDisk();
|
||||
}
|
||||
if (actions == null) {
|
||||
actions = new Actions();
|
||||
}
|
||||
if (actions.getActions() == null) {
|
||||
actions.setActions(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private boolean isDue(Action action, Timestamp now) {
|
||||
if (action.latestExecuteTime == null) {
|
||||
action.latestExecuteTime = action.addTime == null ? now : action.addTime;
|
||||
}
|
||||
|
||||
Instant target = action.latestExecuteTime.toInstant();
|
||||
return !target.isAfter(now.toInstant());
|
||||
}
|
||||
|
||||
private boolean broadcastCountdownIfNeeded(Action action, Timestamp now) {
|
||||
Instant nextRunTime = nextExecuteTime(action, now);
|
||||
if (!action.displayRemainingTime || countdownMessageKey(action) == null || nextRunTime == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long remainingMillis = nextRunTime.toEpochMilli() - now.toInstant().toEpochMilli();
|
||||
if (remainingMillis <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long remainingSeconds = Math.max(1, (remainingMillis + 999) / 1000);
|
||||
if (action.latestCountdownRemainSec == remainingSeconds || !isCountdownMilestone(action, remainingSeconds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
action.latestCountdownRemainSec = remainingSeconds;
|
||||
if (action.displayAtActionBar) {
|
||||
broadcastAtActionBar(countdownMessageKey(action), "time", formatRemainingTime(remainingSeconds));
|
||||
} else {
|
||||
broadcastNow(countdownMessageKey(action), "time", formatRemainingTime(remainingSeconds));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String countdownMessageKey(Action action) {
|
||||
return switch (action.targetActionName) {
|
||||
case "action.server.stop" -> "action.server.stop.countdown";
|
||||
case "action.item.clear" -> "action.item.clear.countdown";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isCountdownMilestone(Action action, long remainingSeconds) {
|
||||
if ("action.server.stop".equals(action.targetActionName)) {
|
||||
return remainingSeconds == 3600
|
||||
|| remainingSeconds == 1800
|
||||
|| remainingSeconds == 600
|
||||
|| remainingSeconds == 300
|
||||
|| remainingSeconds == 60
|
||||
|| remainingSeconds == 30
|
||||
|| remainingSeconds <= 10;
|
||||
}
|
||||
|
||||
return (remainingSeconds >= 60 && remainingSeconds % 60 == 0)
|
||||
|| remainingSeconds == 30
|
||||
|| remainingSeconds == 10;
|
||||
}
|
||||
|
||||
private String formatRemainingTime(long remainingSeconds) {
|
||||
if (remainingSeconds >= 3600 && remainingSeconds % 3600 == 0) {
|
||||
return (remainingSeconds / 3600) + "h";
|
||||
}
|
||||
if (remainingSeconds >= 60 && remainingSeconds % 60 == 0) {
|
||||
return (remainingSeconds / 60) + "m";
|
||||
}
|
||||
return remainingSeconds + "s";
|
||||
}
|
||||
|
||||
private boolean normalizeAction(Action action) {
|
||||
if (action.doOnce) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int minimumIntervalSec = minimumExecuteIntervalMinute(action.targetActionName) * 60;
|
||||
if (action.executeIntervalSec >= minimumIntervalSec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
action.executeIntervalSec = minimumIntervalSec;
|
||||
return true;
|
||||
}
|
||||
|
||||
private int effectiveExecuteIntervalSec(Action action) {
|
||||
return Math.max(minimumExecuteIntervalMinute(action.targetActionName) * 60, action.executeIntervalSec);
|
||||
}
|
||||
|
||||
private int minimumExecuteIntervalMinute(String actionName) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private String formatInterval(int intervalSec) {
|
||||
if (intervalSec % 60 == 0) {
|
||||
return (intervalSec / 60) + "m";
|
||||
}
|
||||
return intervalSec + "s";
|
||||
}
|
||||
|
||||
private void executeAction(Action action) {
|
||||
switch (action.targetActionName) {
|
||||
case "action.server.stop" -> {
|
||||
logger.info("Executing scheduled server stop action '{}'.", action.name);
|
||||
Bukkit.shutdown();
|
||||
}
|
||||
case "action.server.reload" -> {
|
||||
logger.info("Executing scheduled server reload action '{}'.", action.name);
|
||||
Bukkit.reload();
|
||||
}
|
||||
case "action.item.clear" -> {
|
||||
int removed = clearDroppedItems();
|
||||
logger.info("Executing scheduled item clear action '{}'. Removed {} dropped items.", action.name, removed);
|
||||
if (action.displayRemainingTime) {
|
||||
if (action.displayAtActionBar) {
|
||||
broadcastAtActionBar("action.item.clear.completed", "count", String.valueOf(removed));
|
||||
} else {
|
||||
broadcastNow("action.item.clear.completed", "count", String.valueOf(removed));
|
||||
}
|
||||
}
|
||||
}
|
||||
default -> logger.warn("Skipping unsupported scheduled action '{}': {}", action.name, action.targetActionName);
|
||||
}
|
||||
}
|
||||
|
||||
private int clearDroppedItems() {
|
||||
int removed = 0;
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
for (Item item : world.getEntitiesByClass(Item.class)) {
|
||||
item.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private void broadcastStartMessage(Action action) {
|
||||
if (action.displayAtActionBar) {
|
||||
broadcastAtActionBar(
|
||||
"action.started",
|
||||
"name",
|
||||
action.name
|
||||
);
|
||||
} else {
|
||||
broadcastNow(
|
||||
"action.started",
|
||||
"name",
|
||||
action.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastAtActionBar(String key, String... replacements) {
|
||||
Bukkit.getOnlinePlayers().forEach(player ->
|
||||
player.sendActionBar(Toozlie.deserializeText(toozlie.messages.get(player, key, replacements)))
|
||||
);
|
||||
}
|
||||
|
||||
private void broadcastNow(String key, String... replacements) {
|
||||
Bukkit.getOnlinePlayers().forEach(player ->
|
||||
player.sendMessage(Toozlie.deserializeText(toozlie.messages.get(player, key, replacements)))
|
||||
);
|
||||
Bukkit.getConsoleSender().sendMessage(Toozlie.deserializeText(
|
||||
toozlie.messages.get(toozlie.messages.defaultLocale(), key, replacements)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
public final class Broadcaster {
|
||||
private HttpServer server;
|
||||
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger("Toozlie.Broadcaster");
|
||||
|
||||
// Very simple per-minute limiter (good enough for localhost automation)
|
||||
private final ConcurrentHashMap<String, AtomicInteger> minuteCounter = new ConcurrentHashMap<>();
|
||||
private volatile long currentMinute = System.currentTimeMillis() / 60000L;
|
||||
|
||||
private final Toozlie toozlie;
|
||||
|
||||
// Broadcast file
|
||||
private File broadcastFile;
|
||||
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
public static class Broadcast {
|
||||
public String name;
|
||||
public Timestamp addTime;
|
||||
public Timestamp latestBroadcastTime;
|
||||
public String content;
|
||||
public boolean display;
|
||||
public int displayIntervalSec = 600;
|
||||
public boolean displayAtActionBar = false;
|
||||
|
||||
public Broadcast() {
|
||||
}
|
||||
|
||||
public Broadcast(String name, Timestamp addTime, Timestamp latestBroadcastTime, String content, boolean display,
|
||||
int displayIntervalSec, boolean displayAtActionBar) {
|
||||
this.name = name;
|
||||
this.addTime = addTime;
|
||||
this.latestBroadcastTime = latestBroadcastTime;
|
||||
this.content = content;
|
||||
this.display = display;
|
||||
this.displayIntervalSec = displayIntervalSec;
|
||||
this.displayAtActionBar = displayAtActionBar;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Broadcasts {
|
||||
private List<Broadcast> broadcasts = new ArrayList<>();
|
||||
|
||||
public Broadcasts() {
|
||||
}
|
||||
|
||||
// Getter & Setter
|
||||
public List<Broadcast> getBroadcasts() { return broadcasts; }
|
||||
public void setBroadcasts(List<Broadcast> broadcasts) {
|
||||
this.broadcasts = broadcasts == null ? new ArrayList<>() : broadcasts;
|
||||
}
|
||||
}
|
||||
|
||||
private Broadcasts broadcasts = new Broadcasts();
|
||||
|
||||
public Broadcaster(Toozlie toozlie) {
|
||||
this.toozlie = toozlie;
|
||||
}
|
||||
|
||||
public static final List<String> SupportHTTPMethod = List.of("POST", "GET");
|
||||
|
||||
private BukkitTask broadcastTask;
|
||||
|
||||
public void startBroadcastTask() {
|
||||
broadcastTask = Bukkit.getScheduler().runTaskTimer(toozlie, () -> {
|
||||
ensureBroadcastsLoaded();
|
||||
|
||||
Timestamp now = Timestamp.from(Instant.now());
|
||||
|
||||
for (Broadcast broadcast : broadcasts.getBroadcasts()) {
|
||||
if (!broadcast.display) continue;
|
||||
|
||||
if (broadcast.latestBroadcastTime == null) {
|
||||
broadcast.latestBroadcastTime = Timestamp.from(Instant.now());
|
||||
}
|
||||
|
||||
Instant last = broadcast.latestBroadcastTime.toInstant();
|
||||
|
||||
long elapsedSeconds = Duration.between(
|
||||
last,
|
||||
now.toInstant()
|
||||
).getSeconds();
|
||||
|
||||
if (elapsedSeconds >= broadcast.displayIntervalSec) {
|
||||
if (broadcast.displayAtActionBar) {
|
||||
broadcastActionBar(broadcast);
|
||||
} else {
|
||||
broadcastNow(broadcast);
|
||||
}
|
||||
broadcast.latestBroadcastTime = now;
|
||||
saveAllBroadcasts();
|
||||
}
|
||||
}
|
||||
}, 20L, 20L);
|
||||
}
|
||||
|
||||
public void startBroadcaster() {
|
||||
loadAllBroadcasts();
|
||||
startBroadcastTask();
|
||||
|
||||
// HTTP Server is deprecated
|
||||
}
|
||||
|
||||
public void stopBroadcaster() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
if (broadcastTask != null) {
|
||||
broadcastTask.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// ========== Broadcast ==========
|
||||
//
|
||||
private void loadAllBroadcasts() {
|
||||
try {
|
||||
broadcastFile = new File(toozlie.getDataFolder(), "broadcasts.yml");
|
||||
|
||||
if (!toozlie.getDataFolder().exists() && !toozlie.getDataFolder().mkdirs()) {
|
||||
logger.warn("Failed to create plugin data folder: {}", toozlie.getDataFolder());
|
||||
}
|
||||
|
||||
if (!broadcastFile.exists() || broadcastFile.length() == 0) {
|
||||
broadcasts = new Broadcasts();
|
||||
saveAllBroadcasts();
|
||||
return;
|
||||
}
|
||||
|
||||
broadcasts = yamlMapper.readValue(broadcastFile, Broadcasts.class);
|
||||
if (broadcasts == null) {
|
||||
broadcasts = new Broadcasts();
|
||||
}
|
||||
if (broadcasts.getBroadcasts() == null) {
|
||||
broadcasts.setBroadcasts(new ArrayList<>());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
logger.error("An error occurred while reading broadcast file. Exec: {}", exception.toString());
|
||||
broadcasts = new Broadcasts();
|
||||
}
|
||||
}
|
||||
|
||||
private Toozlie.Result<Boolean> saveAllBroadcasts() {
|
||||
try {
|
||||
if (broadcastFile == null) {
|
||||
broadcastFile = new File(toozlie.getDataFolder(), "broadcasts.yml");
|
||||
}
|
||||
yamlMapper.writeValue(broadcastFile, broadcasts);
|
||||
return Toozlie.Result.success(true);
|
||||
} catch (Exception exception) {
|
||||
logger.error("An error occurred while saving broadcast file. Exec: {}", exception.toString());
|
||||
return Toozlie.Result.error("Failed to save broadcast file.");
|
||||
}
|
||||
}
|
||||
|
||||
public void addBroadcast(String name, String content, boolean display, int displayIntervalSec, boolean displayAtActionBar) {
|
||||
ensureBroadcastsLoaded();
|
||||
Broadcast broadcast = new Broadcast(name, Timestamp.from(Instant.now()),
|
||||
Timestamp.from(Instant.now()), content, display, displayIntervalSec, displayAtActionBar);
|
||||
broadcasts.getBroadcasts().add(broadcast);
|
||||
saveAllBroadcasts();
|
||||
}
|
||||
|
||||
public Toozlie.Result<Boolean> removeBroadcast(int index) {
|
||||
ensureBroadcastsLoaded();
|
||||
if (broadcasts.getBroadcasts().isEmpty()) {
|
||||
return Toozlie.Result.error("Broadcast list are empty.");
|
||||
}
|
||||
|
||||
if (index < 0 || broadcasts.getBroadcasts().size() < index+1) {
|
||||
return Toozlie.Result.error("Broadcast index out of range.");
|
||||
}
|
||||
|
||||
broadcasts.getBroadcasts().remove(index);
|
||||
|
||||
return saveAllBroadcasts();
|
||||
}
|
||||
|
||||
private void ensureBroadcastsLoaded() {
|
||||
if (broadcastFile == null) {
|
||||
loadAllBroadcasts();
|
||||
}
|
||||
if (broadcasts == null) {
|
||||
broadcasts = new Broadcasts();
|
||||
}
|
||||
if (broadcasts.getBroadcasts() == null) {
|
||||
broadcasts.setBroadcasts(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
public static void broadcastActionBar(Broadcast broadcast) {
|
||||
Bukkit.getOnlinePlayers().forEach(player -> {
|
||||
player.sendActionBar(Toozlie.deserializeText(broadcast.content));
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastNow(Broadcast broadcast) {
|
||||
Bukkit.broadcast(Toozlie.deserializeText(broadcast.content));
|
||||
}
|
||||
|
||||
//
|
||||
// ========== Command ==========
|
||||
//
|
||||
private final int PAGE_SIZE = 3;
|
||||
|
||||
public boolean list(CommandSender p, int page) {
|
||||
if (broadcasts.getBroadcasts().isEmpty()) {
|
||||
p.sendMessage(toozlie.messages.get(p, "broadcaster.none"));
|
||||
return true;
|
||||
}
|
||||
|
||||
int total = broadcasts.broadcasts.size();
|
||||
int maxPage = Math.max(1, (int) Math.ceil(total / (double) PAGE_SIZE));
|
||||
page = Math.min(Math.max(page, 1), maxPage);
|
||||
|
||||
int start = (page - 1) * PAGE_SIZE;
|
||||
int end = Math.min(start + PAGE_SIZE, total);
|
||||
|
||||
p.sendMessage(toozlie.messages.get(
|
||||
p,
|
||||
"broadcaster.header",
|
||||
"page",
|
||||
String.valueOf(page),
|
||||
"maxPage",
|
||||
String.valueOf(maxPage),
|
||||
"total",
|
||||
String.valueOf(total)
|
||||
));
|
||||
for (int i = start; i < end; i++) {
|
||||
sendOne(p, broadcasts.broadcasts.get(i), i + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter TS_FMT = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
private void sendOne(CommandSender p, Broadcast broadcast, int index) {
|
||||
String message = toozlie.messages.get(
|
||||
p,
|
||||
"broadcaster.entry",
|
||||
"index",
|
||||
String.valueOf(index),
|
||||
"name",
|
||||
broadcast.name,
|
||||
"content",
|
||||
broadcast.content,
|
||||
"time",
|
||||
TS_FMT.format(broadcast.addTime.toInstant())
|
||||
);
|
||||
Component line = Toozlie.deserializeText(message);
|
||||
p.sendMessage(line);
|
||||
}
|
||||
|
||||
//
|
||||
// ========== HTTP Server ==========
|
||||
//
|
||||
private String readBodyOnce(HttpExchange ex) throws IOException {
|
||||
Object cached = ex.getAttribute("cachedBody");
|
||||
if (cached instanceof String) {
|
||||
return (String) cached;
|
||||
}
|
||||
|
||||
InputStream is = ex.getRequestBody();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[512];
|
||||
int r;
|
||||
int total = 0;
|
||||
|
||||
while ((r = is.read(buf)) != -1) {
|
||||
total += r;
|
||||
if (total > toozlie.config.whitelistPortal.security.maxBodyBytes) {
|
||||
throw new IOException("Request body too large");
|
||||
}
|
||||
baos.write(buf, 0, r);
|
||||
}
|
||||
|
||||
String body = baos.toString(StandardCharsets.UTF_8);
|
||||
ex.setAttribute("cachedBody", body);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void startHttp() throws IOException {
|
||||
InetSocketAddress addr = new InetSocketAddress(
|
||||
toozlie.config.whitelistPortal.host,
|
||||
toozlie.config.whitelistPortal.port);
|
||||
server = HttpServer.create(addr, 0);
|
||||
|
||||
server.createContext("/broadcast/list", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, this::buildBroadcastListJson)
|
||||
));
|
||||
|
||||
server.createContext("/broadcast/add", ex -> handleAuthed(ex, () -> {
|
||||
Map<String, Object> payload;
|
||||
try {
|
||||
payload = parseJsonObject(readBodyOnce(ex));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
runSyncAndRespond(ex, () -> {
|
||||
String name = stringValue(payload.get("name"));
|
||||
String content = stringValue(payload.get("content"));
|
||||
boolean display = booleanValue(payload.get("display"), true);
|
||||
int displayIntervalSec = intValue(payload.get("displayIntervalSec"), 600);
|
||||
boolean displayAtActionVar = booleanValue(payload.get("displayAtActionVar"), true);
|
||||
|
||||
if (name.isBlank()) return jsonFail("missing broadcast name");
|
||||
if (content.isBlank()) return jsonFail("missing broadcast content");
|
||||
if (displayIntervalSec <= 0) return jsonFail("displayIntervalSec must be greater than zero");
|
||||
|
||||
addBroadcast(name, content, display, displayIntervalSec, displayAtActionVar);
|
||||
logger.info("Broadcast '{}' is added.", name);
|
||||
return jsonOk("broadcast added");
|
||||
});
|
||||
}));
|
||||
|
||||
server.createContext("/broadcast/remove", ex -> handleAuthed(ex, () -> {
|
||||
Map<String, Object> payload;
|
||||
try {
|
||||
payload = parseJsonObject(readBodyOnce(ex));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
runSyncAndRespond(ex, () -> {
|
||||
int index = intValue(payload.get("index"), -1);
|
||||
Toozlie.Result<Boolean> result = removeBroadcast(index);
|
||||
if (!result.isSuccess()) return jsonFail(result.getMessage());
|
||||
logger.info("Broadcast at index {} is removed.", index);
|
||||
return jsonOk("broadcast removed");
|
||||
});
|
||||
}));
|
||||
|
||||
// Thread pool for HTTP handling (Bukkit actions still run sync)
|
||||
server.setExecutor(Executors.newFixedThreadPool(4));
|
||||
server.start();
|
||||
}
|
||||
|
||||
private String buildBroadcastListJson() {
|
||||
ensureBroadcastsLoaded();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{\"ok\":true,\"broadcasts\":[");
|
||||
|
||||
boolean first = true;
|
||||
List<Broadcast> list = broadcasts.getBroadcasts();
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
Broadcast broadcast = list.get(i);
|
||||
if (!first) sb.append(",");
|
||||
first = false;
|
||||
|
||||
sb.append("{");
|
||||
sb.append("\"index\":").append(i).append(",");
|
||||
sb.append("\"name\":\"").append(escape(nullToEmpty(broadcast.name))).append("\",");
|
||||
sb.append("\"addTime\":").append(timestampJsonValue(broadcast.addTime)).append(",");
|
||||
sb.append("\"latestBroadcastTime\":").append(timestampJsonValue(broadcast.latestBroadcastTime)).append(",");
|
||||
sb.append("\"content\":\"").append(escape(nullToEmpty(broadcast.content))).append("\",");
|
||||
sb.append("\"display\":").append(broadcast.display).append(",");
|
||||
sb.append("\"displayIntervalSec\":").append(broadcast.displayIntervalSec);
|
||||
sb.append("}");
|
||||
}
|
||||
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void handleAuthed(HttpExchange ex, Runnable authedAction) throws IOException {
|
||||
try {
|
||||
// Method restriction
|
||||
if (!toozlie.config.whitelistPortal.security.requireMethod.equalsIgnoreCase(ex.getRequestMethod())) {
|
||||
writeJson(ex, 405, jsonFail("method not allowed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit per minute per IP (localhost typically)
|
||||
if (!allowRequest(ex)) {
|
||||
writeJson(ex, 429, jsonFail("rate limited"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic path sanity
|
||||
if (!isAuthed(ex)) {
|
||||
writeJson(ex, 401, jsonFail("unauthorized"));
|
||||
return;
|
||||
}
|
||||
|
||||
authedAction.run();
|
||||
} catch (Exception e) {
|
||||
logger.warn("HTTP error: {}", e.getMessage());
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allowRequest(HttpExchange ex) {
|
||||
long minute = System.currentTimeMillis() / 60000L;
|
||||
if (minute != currentMinute) {
|
||||
currentMinute = minute;
|
||||
minuteCounter.clear();
|
||||
}
|
||||
String key = ex.getRemoteAddress().getAddress().getHostAddress();
|
||||
AtomicInteger c = minuteCounter.computeIfAbsent(key, k -> new AtomicInteger(0));
|
||||
return c.incrementAndGet() <= toozlie.config.whitelistPortal.security.maxRequestsPerMinute;
|
||||
}
|
||||
|
||||
private boolean isAuthed(HttpExchange ex) {
|
||||
Headers h = ex.getRequestHeaders();
|
||||
|
||||
if ("token".equals(toozlie.config.whitelistPortal.auth.mode)) {
|
||||
String auth = h.getFirst("Authorization");
|
||||
if (auth == null) return false;
|
||||
String prefix = "Bearer ";
|
||||
if (!auth.startsWith(prefix)) return false;
|
||||
String got = auth.substring(prefix.length()).trim();
|
||||
return constantTimeEquals(got, toozlie.config.whitelistPortal.auth.token);
|
||||
}
|
||||
|
||||
if ("hmac".equals(toozlie.config.whitelistPortal.auth.mode)) {
|
||||
String sig = h.getFirst("X-Signature");
|
||||
String ts = h.getFirst("X-Timestamp");
|
||||
if (sig == null || ts == null) return false;
|
||||
|
||||
long now = Instant.now().getEpochSecond();
|
||||
long t;
|
||||
try { t = Long.parseLong(ts); } catch (Exception e) { return false; }
|
||||
if (Math.abs(now - t) > toozlie.config.whitelistPortal.auth.allowSkewSeconds) return false;
|
||||
|
||||
// Signature over: method + "\n" + path + "\n" + timestamp + "\n" + body
|
||||
String body = "";
|
||||
try {
|
||||
body = readBody(ex); // consumes stream; store in attribute for later usage
|
||||
ex.setAttribute("cachedBody", body);
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
String data = ex.getRequestMethod() + "\n" + ex.getRequestURI().getPath() + "\n" + ts + "\n" + body;
|
||||
String expected = hmacHex(toozlie.config.whitelistPortal.auth.hmacSecret, data);
|
||||
return constantTimeEquals(sig.toLowerCase(Locale.ROOT), expected.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
// Unknown mode -> deny
|
||||
return false;
|
||||
}
|
||||
|
||||
private String readBody(HttpExchange ex) throws IOException {
|
||||
try (InputStream is = ex.getRequestBody()) {
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private void runSyncAndRespond(HttpExchange ex, Callable<String> action) {
|
||||
try {
|
||||
// Run Bukkit actions on the main server thread and wait for the result.
|
||||
Future<String> future = Bukkit.getScheduler().callSyncMethod(toozlie, action);
|
||||
|
||||
String json;
|
||||
try {
|
||||
json = future.get();
|
||||
} catch (ExecutionException ee) {
|
||||
logger.error("Sync task error: {}", String.valueOf(ee.getCause()));
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(ex, 200, json);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("HTTP handler error");
|
||||
e.printStackTrace();
|
||||
|
||||
try {
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
} catch (IOException io) {
|
||||
io.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeJson(HttpExchange ex, int code, String json) throws IOException {
|
||||
byte[] data = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Headers h = ex.getResponseHeaders();
|
||||
h.set("Content-Type", "application/json; charset=utf-8");
|
||||
h.set("Cache-Control", "no-store");
|
||||
h.set("Connection", "close");
|
||||
|
||||
ex.sendResponseHeaders(code, data.length);
|
||||
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(data);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
ex.close();
|
||||
}
|
||||
|
||||
private OfflinePlayer resolveOfflinePlayer(Map<String, String> payload) {
|
||||
|
||||
String floodgateUuid = payload.get("floodgateUuid");
|
||||
if (floodgateUuid != null && !floodgateUuid.isBlank()) {
|
||||
try {
|
||||
UUID id = UUID.fromString(floodgateUuid.trim());
|
||||
return Bukkit.getOfflinePlayer(id);
|
||||
} catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
|
||||
String uuid = payload.get("uuid");
|
||||
if (uuid != null && !uuid.isBlank()) {
|
||||
try {
|
||||
UUID id = UUID.fromString(uuid.trim());
|
||||
return Bukkit.getOfflinePlayer(id);
|
||||
} catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
|
||||
String name = payload.get("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
|
||||
for (var p : Bukkit.getOnlinePlayers()) {
|
||||
if (p.getName().equalsIgnoreCase(name.trim())) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return Bukkit.getOfflinePlayer(name.trim());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Tiny JSON parser for {"name":"x"} / {"uuid":"..."} only
|
||||
private static final Pattern KV = Pattern.compile("\"(floodgateUuid|uuid|name)\"\\s*:\\s*\"([^\"]*)\"");
|
||||
private Map<String, String> parseTinyJson(String body) {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
if (body == null) return m;
|
||||
Matcher matcher = KV.matcher(body);
|
||||
while (matcher.find()) {
|
||||
m.put(matcher.group(1), matcher.group(2));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> parseJsonObject(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
try {
|
||||
Object parsed = jsonMapper.readValue(body, Map.class);
|
||||
if (parsed instanceof Map<?, ?> map) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
result.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private boolean booleanValue(Object value, boolean fallback) {
|
||||
if (value instanceof Boolean bool) {
|
||||
return bool;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return Boolean.parseBoolean(text.trim());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private int intValue(Object value, int fallback) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
try {
|
||||
return Integer.parseInt(text.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String timestampJsonValue(Timestamp timestamp) {
|
||||
return timestamp == null ? "null" : "\"" + escape(timestamp.toInstant().toString()) + "\"";
|
||||
}
|
||||
|
||||
private String jsonOk(String message) {
|
||||
return "{\"ok\":true,\"message\":\"" + escape(message) + "\"}";
|
||||
}
|
||||
|
||||
private String jsonFail(String message) {
|
||||
return "{\"ok\":false,\"message\":\"" + escape(message) + "\"}";
|
||||
}
|
||||
|
||||
private String display(OfflinePlayer p) {
|
||||
String name = p.getName();
|
||||
return (name == null ? "null" : name) + " (" + p.getUniqueId() + ")";
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null) return false;
|
||||
byte[] x = a.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] y = b.getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(x, y);
|
||||
}
|
||||
|
||||
private static String hmacHex(String secret, String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] out = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : out) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
public final class DeathPosCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private final DeathStore store;
|
||||
private final Toozlie toozlie;
|
||||
|
||||
private static final int PAGE_SIZE = 5;
|
||||
|
||||
private static final DateTimeFormatter TS_FMT = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
public DeathPosCommand(DeathSaver plugin, DeathStore store) {
|
||||
this.store = store;
|
||||
this.toozlie = plugin.getToozlie();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player p)) {
|
||||
sender.sendMessage(toozlie.messages.get(sender, "generic.players-only"));
|
||||
return true;
|
||||
}
|
||||
if (!p.hasPermission("deathpos.use")) {
|
||||
p.sendMessage(toozlie.messages.get(p, "generic.no-permission"));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.length == 0) {
|
||||
return list(p, 1);
|
||||
}
|
||||
|
||||
switch (args[0].toLowerCase(Locale.ROOT)) {
|
||||
case "list" -> {
|
||||
int page = 1;
|
||||
if (args.length >= 2) page = parseIntOr(args[1], 1);
|
||||
return list(p, page);
|
||||
}
|
||||
case "last" -> {
|
||||
return showLast(p);
|
||||
}
|
||||
case "tp" -> {
|
||||
if (!p.hasPermission("deathpos.tp")) {
|
||||
p.sendMessage(toozlie.messages.get(p, "generic.no-permission"));
|
||||
return true;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.usage.tp"));
|
||||
return true;
|
||||
}
|
||||
int idx = parseIntOr(args[1], -1);
|
||||
return tp(p, idx);
|
||||
}
|
||||
case "clear" -> {
|
||||
if (!p.hasPermission("deathpos.clear")) {
|
||||
p.sendMessage(toozlie.messages.get(p, "generic.no-permission"));
|
||||
return true;
|
||||
}
|
||||
store.clearDeaths(p.getUniqueId());
|
||||
p.sendMessage(toozlie.messages.get(p, "death.cleared"));
|
||||
return true;
|
||||
}
|
||||
default -> {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.usage"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean showLast(Player p) {
|
||||
var deaths = store.getDeaths(p.getUniqueId());
|
||||
if (deaths.isEmpty()) {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.none"));
|
||||
return true;
|
||||
}
|
||||
sendOne(p, deaths.get(0), 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean list(Player p, int page) {
|
||||
var deaths = store.getDeaths(p.getUniqueId());
|
||||
if (deaths.isEmpty()) {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.none"));
|
||||
return true;
|
||||
}
|
||||
|
||||
int total = deaths.size();
|
||||
int maxPage = Math.max(1, (int) Math.ceil(total / (double) PAGE_SIZE));
|
||||
page = Math.min(Math.max(page, 1), maxPage);
|
||||
|
||||
int start = (page - 1) * PAGE_SIZE;
|
||||
int end = Math.min(start + PAGE_SIZE, total);
|
||||
|
||||
p.sendMessage(toozlie.messages.get(
|
||||
p,
|
||||
"death.header",
|
||||
"page",
|
||||
String.valueOf(page),
|
||||
"maxPage",
|
||||
String.valueOf(maxPage),
|
||||
"total",
|
||||
String.valueOf(total)
|
||||
));
|
||||
for (int i = start; i < end; i++) {
|
||||
sendOne(p, deaths.get(i), i + 1);
|
||||
}
|
||||
p.sendMessage(toozlie.messages.get(p, "death.tip"));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sendOne(Player p, DeathStore.DeathPoint dp, int index1Based) {
|
||||
String time = TS_FMT.format(Instant.ofEpochSecond(dp.epochSeconds()));
|
||||
String deathMessage = dp.deathMessage().isBlank()
|
||||
? ""
|
||||
: toozlie.messages.get(p, "death.entry.message", "message", dp.deathMessage());
|
||||
p.sendMessage(toozlie.messages.get(
|
||||
p,
|
||||
"death.entry",
|
||||
"index",
|
||||
String.valueOf(index1Based),
|
||||
"time",
|
||||
time,
|
||||
"world",
|
||||
dp.world(),
|
||||
"xyz",
|
||||
formatXYZ(dp.x(), dp.y(), dp.z()),
|
||||
"message",
|
||||
deathMessage
|
||||
));
|
||||
}
|
||||
|
||||
public boolean tp(Player p, int index1Based) {
|
||||
var deaths = store.getDeaths(p.getUniqueId());
|
||||
if (index1Based < 1 || index1Based > deaths.size()) {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.invalid-index"));
|
||||
return true;
|
||||
}
|
||||
|
||||
DeathStore.DeathPoint dp = deaths.get(index1Based - 1);
|
||||
Location loc = store.toLocation(dp);
|
||||
if (loc == null || loc.getWorld() == null) {
|
||||
p.sendMessage(toozlie.messages.get(p, "death.world-not-loaded", "world", dp.world()));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 可選:避免卡方塊,傳送到方塊上方
|
||||
loc.setX(Math.floor(loc.getX()) + 0.5);
|
||||
loc.setY(Math.floor(loc.getY()) + 0.2);
|
||||
loc.setZ(Math.floor(loc.getZ()) + 0.5);
|
||||
|
||||
p.teleport(loc);
|
||||
p.sendMessage(toozlie.messages.get(p, "death.teleported", "index", String.valueOf(index1Based)));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int parseIntOr(String s, int def) {
|
||||
try { return Integer.parseInt(s); } catch (Exception e) { return def; }
|
||||
}
|
||||
|
||||
private static String formatXYZ(double x, double y, double z) {
|
||||
return "(" + (int)Math.floor(x) + ", " + (int)Math.floor(y) + ", " + (int)Math.floor(z) + ")";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!(sender instanceof Player)) return List.of();
|
||||
|
||||
if (args.length == 1) {
|
||||
return partial(args[0], List.of("list", "last", "tp", "clear"));
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("tp")) {
|
||||
// 不掃實際筆數(避免 IO),給一些常用
|
||||
return partial(args[1], List.of("1", "2", "3", "4", "5"));
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> partial(String token, List<String> options) {
|
||||
String t = token.toLowerCase(Locale.ROOT);
|
||||
ArrayList<String> out = new ArrayList<>();
|
||||
for (String o : options) {
|
||||
if (o.toLowerCase(Locale.ROOT).startsWith(t)) out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
public final class DeathSaver {
|
||||
public DeathStore store;
|
||||
private final Toozlie toozlie;
|
||||
public DeathPosCommand cmd;
|
||||
|
||||
public DeathSaver(Toozlie plugin) {
|
||||
this.toozlie = plugin;
|
||||
}
|
||||
|
||||
public void startSaver() {
|
||||
// Start store
|
||||
this.store = new DeathStore(toozlie);
|
||||
|
||||
// Commands
|
||||
cmd = new DeathPosCommand(this, store);
|
||||
|
||||
toozlie.getServer().getPluginManager().registerEvents(store, toozlie);
|
||||
}
|
||||
|
||||
public void stopSaver() {
|
||||
if (store != null) store.flush();
|
||||
}
|
||||
|
||||
public Toozlie getToozlie() {
|
||||
return toozlie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
public final class DeathStore implements Listener {
|
||||
|
||||
public record DeathPoint(
|
||||
String world,
|
||||
double x, double y, double z,
|
||||
float yaw, float pitch,
|
||||
long epochSeconds,
|
||||
String deathMessage
|
||||
) {}
|
||||
|
||||
private final Toozlie plugin;
|
||||
private final File file;
|
||||
private YamlConfiguration yaml;
|
||||
|
||||
// UUID -> newest first
|
||||
private final Map<UUID, Deque<DeathPoint>> cache = new HashMap<>();
|
||||
|
||||
public DeathStore(Toozlie plugin) {
|
||||
this.plugin = plugin;
|
||||
this.file = new File(plugin.getDataFolder(), "deaths.yml");
|
||||
reload();
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs();
|
||||
this.yaml = YamlConfiguration.loadConfiguration(file);
|
||||
this.cache.clear();
|
||||
|
||||
// 讀回 cache
|
||||
var root = yaml.getConfigurationSection("players");
|
||||
if (root == null) return;
|
||||
|
||||
for (String uuidStr : root.getKeys(false)) {
|
||||
UUID uuid;
|
||||
try { uuid = UUID.fromString(uuidStr); }
|
||||
catch (IllegalArgumentException ex) { continue; }
|
||||
|
||||
var list = root.getMapList(uuidStr + ".list");
|
||||
Deque<DeathPoint> dq = new ArrayDeque<>();
|
||||
|
||||
for (Map<?, ?> m : list) {
|
||||
try {
|
||||
String world = String.valueOf(m.get("world"));
|
||||
double x = ((Number) m.get("x")).doubleValue();
|
||||
double y = ((Number) m.get("y")).doubleValue();
|
||||
double z = ((Number) m.get("z")).doubleValue();
|
||||
float yaw = ((Number) m.get("yaw")).floatValue();
|
||||
float pitch = ((Number) m.get("pitch")).floatValue();
|
||||
long t = ((Number) m.get("time")).longValue();
|
||||
String msg = m.get("msg") == null ? "" : String.valueOf(m.get("msg"));
|
||||
dq.addLast(new DeathPoint(world, x, y, z, yaw, pitch, t, msg));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
cache.put(uuid, dq);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DeathPoint> getDeaths(UUID uuid) {
|
||||
Deque<DeathPoint> dq = cache.getOrDefault(uuid, new ArrayDeque<>());
|
||||
return List.copyOf(dq);
|
||||
}
|
||||
|
||||
public void clearDeaths(UUID uuid) {
|
||||
cache.remove(uuid);
|
||||
yaml.set("players." + uuid + ".list", new ArrayList<>());
|
||||
saveNow();
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
saveNow();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
Player p = event.getEntity();
|
||||
Location loc = p.getLocation();
|
||||
|
||||
DeathPoint dp = new DeathPoint(
|
||||
loc.getWorld().getName(),
|
||||
loc.getX(), loc.getY(), loc.getZ(),
|
||||
loc.getYaw(), loc.getPitch(),
|
||||
Instant.now().getEpochSecond(),
|
||||
event.getDeathMessage() == null ? "" : event.getDeathMessage()
|
||||
);
|
||||
|
||||
Deque<DeathPoint> dq = cache.computeIfAbsent(p.getUniqueId(), k -> new ArrayDeque<>());
|
||||
// newest first:放前面
|
||||
dq.addFirst(dp);
|
||||
// 你想限制每人最多保留幾筆(避免檔案無限長)
|
||||
int maxPerPlayer = 100;
|
||||
while (dq.size() > maxPerPlayer) dq.removeLast();
|
||||
|
||||
// 寫入 yaml(簡單做法:每次死亡就落盤;想更省 IO 可改成排程批次寫)
|
||||
writePlayerToYaml(p.getUniqueId(), dq);
|
||||
saveNow();
|
||||
p.sendMessage(plugin.messages.get(
|
||||
p,
|
||||
"death.saved",
|
||||
"x",
|
||||
String.format("%.0f", dp.x),
|
||||
"y",
|
||||
String.format("%.0f", dp.y),
|
||||
"z",
|
||||
String.format("%.0f", dp.z)
|
||||
));
|
||||
}
|
||||
|
||||
private void writePlayerToYaml(UUID uuid, Deque<DeathPoint> dq) {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (DeathPoint dp : dq) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("world", dp.world());
|
||||
m.put("x", dp.x());
|
||||
m.put("y", dp.y());
|
||||
m.put("z", dp.z());
|
||||
m.put("yaw", dp.yaw());
|
||||
m.put("pitch", dp.pitch());
|
||||
m.put("time", dp.epochSeconds());
|
||||
m.put("msg", dp.deathMessage());
|
||||
list.add(m);
|
||||
}
|
||||
yaml.set("players." + uuid + ".list", list);
|
||||
}
|
||||
|
||||
private void saveNow() {
|
||||
try {
|
||||
yaml.save(file);
|
||||
} catch (IOException e) {
|
||||
Bukkit.getLogger().warning("[DeathPoint] Failed to save deaths.yml: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Location toLocation(DeathPoint dp) {
|
||||
var w = Bukkit.getWorld(dp.world());
|
||||
if (w == null) return null;
|
||||
Location loc = new Location(w, dp.x(), dp.y(), dp.z(), dp.yaw(), dp.pitch());
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import me.clip.placeholderapi.PlaceholderAPI;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public class JoinListener implements Listener {
|
||||
private final Toozlie toozlie;
|
||||
|
||||
public JoinListener(Toozlie plugin) {
|
||||
this.toozlie = plugin;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
if (!toozlie.config.showModifiedJoinMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getPlayer();
|
||||
Component joinMessage;
|
||||
|
||||
if (!player.hasPlayedBefore()) {
|
||||
joinMessage = Component.text()
|
||||
.append(Component.text(PlaceholderAPI.setPlaceholders(player, toozlie.config.welcomeMessage)
|
||||
, NamedTextColor.YELLOW))
|
||||
.build();
|
||||
} else {
|
||||
joinMessage = Component.text()
|
||||
.append(Component.text(PlaceholderAPI.setPlaceholders(player, toozlie.config.joinMessage)
|
||||
, NamedTextColor.YELLOW))
|
||||
.build();
|
||||
}
|
||||
|
||||
event.joinMessage(joinMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.incendo.cloud.description.CommandDescription;
|
||||
import org.incendo.cloud.minecraft.extras.AudienceProvider;
|
||||
import org.incendo.cloud.paper.LegacyPaperCommandManager;
|
||||
import org.incendo.cloud.minecraft.extras.MinecraftHelp;
|
||||
import org.incendo.cloud.parser.standard.BooleanParser;
|
||||
import org.incendo.cloud.parser.standard.IntegerParser;
|
||||
import org.incendo.cloud.parser.standard.StringParser;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class MainCommand {
|
||||
private final Toozlie toozlie;
|
||||
private final Messages messages;
|
||||
|
||||
public MainCommand(LegacyPaperCommandManager<CommandSender> manager, Toozlie plugin) {
|
||||
toozlie = plugin;
|
||||
messages = plugin.messages;
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("reload")
|
||||
.permission("tze.admin")
|
||||
.handler(ctx -> {
|
||||
toozlie.reload();
|
||||
ctx.sender().sendMessage(messages.get(ctx.sender(), "command.reload"));
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.reload")
|
||||
)
|
||||
)
|
||||
);
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("version")
|
||||
.permission("tze.admin")
|
||||
.handler(ctx -> {
|
||||
ctx.sender().sendMessage(Component.text(messages.get(
|
||||
ctx.sender(),
|
||||
"command.version",
|
||||
"version",
|
||||
toozlie.getPluginMeta().getVersion()
|
||||
)));
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.version")
|
||||
))
|
||||
);
|
||||
|
||||
// Help command
|
||||
MinecraftHelp<CommandSender> help = MinecraftHelp.<CommandSender>builder()
|
||||
.commandManager(manager)
|
||||
.audienceProvider(AudienceProvider.nativeAudience())
|
||||
.commandPrefix("/help")
|
||||
.messageProvider(this::helpMessage)
|
||||
.build();
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("help")
|
||||
.optional(
|
||||
"query",
|
||||
StringParser.greedyStringParser()
|
||||
)
|
||||
.handler(ctx -> {
|
||||
|
||||
String query =
|
||||
ctx.getOrDefault(
|
||||
"query",
|
||||
""
|
||||
);
|
||||
|
||||
help.queryCommands(
|
||||
query,
|
||||
ctx.sender()
|
||||
);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.help")
|
||||
))
|
||||
);
|
||||
|
||||
// DeathSaver
|
||||
manager.command(
|
||||
manager.commandBuilder("deathpos")
|
||||
.literal("list")
|
||||
.permission("tze.deathpos.list")
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureDeathSaverEnabled(sender)) return;
|
||||
|
||||
if (sender instanceof Player player) {
|
||||
toozlie.deathSaver.cmd.list(player, 1);
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.players-only"));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.deathpos.list")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("deathpos")
|
||||
.literal("latest")
|
||||
.permission("tze.deathpos.latest")
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureDeathSaverEnabled(sender)) return;
|
||||
|
||||
if (sender instanceof Player player) {
|
||||
toozlie.deathSaver.cmd.showLast(player);
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.players-only"));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.deathpos.latest")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("deathpos")
|
||||
.literal("tp")
|
||||
.permission("tze.deathpos.tp")
|
||||
.required("index", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureDeathSaverEnabled(sender)) return;
|
||||
|
||||
int index = ctx.get("index");
|
||||
if (sender instanceof Player player) {
|
||||
toozlie.deathSaver.cmd.tp(player, index);
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.players-only"));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.deathpos.tp")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("deathpos")
|
||||
.literal("clear")
|
||||
.permission("tze.deathpos.clear")
|
||||
.required("index", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureDeathSaverEnabled(sender)) return;
|
||||
|
||||
if (sender instanceof Player player) {
|
||||
toozlie.deathSaver.store.clearDeaths(player.getUniqueId());
|
||||
player.sendMessage(messages.get(player, "death.cleared"));
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.players-only"));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.deathpos.clear")
|
||||
))
|
||||
);
|
||||
|
||||
// Broadcaster
|
||||
manager.command(
|
||||
manager.commandBuilder("broadcaster")
|
||||
.literal("list")
|
||||
.permission("tze.broadcaster.list")
|
||||
.optional("index", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureBroadcasterEnabled(sender)) return;
|
||||
|
||||
int index = ctx.getOrDefault("index", 1);
|
||||
toozlie.broadcaster.list(sender, index);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.broadcaster.list")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("broadcaster")
|
||||
.literal("add")
|
||||
.permission("tze.broadcaster.add")
|
||||
.required("name", StringParser.stringParser())
|
||||
.required("content", StringParser.quotedStringParser())
|
||||
.optional("display", BooleanParser.booleanParser())
|
||||
.optional("displayIntervalSecond", IntegerParser.integerParser())
|
||||
.optional("displayAtActionBar", BooleanParser.booleanParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureBroadcasterEnabled(sender)) return;
|
||||
|
||||
String name = ctx.get("name");
|
||||
String content = ctx.get("content");
|
||||
boolean display = ctx.getOrDefault("display", false);
|
||||
int displayIntervalSecond = ctx.getOrDefault("displayIntervalSecond", 600);
|
||||
boolean displayAtActionBar = ctx.getOrDefault("displayAtActionBar", false);
|
||||
toozlie.broadcaster.addBroadcast(name, content,
|
||||
display, displayIntervalSecond, displayAtActionBar);
|
||||
sender.sendMessage(messages.get(sender, "broadcaster.added", "name", name));
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.broadcaster.add")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("broadcaster")
|
||||
.literal("remove")
|
||||
.permission("tze.broadcaster.list")
|
||||
.required("index", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
|
||||
if (!ensureBroadcasterEnabled(sender)) return;
|
||||
|
||||
int index = ctx.get("index");
|
||||
Toozlie.Result<Boolean> result = toozlie.broadcaster.removeBroadcast(index-1); // convert it back to 0-based index
|
||||
if (result.isSuccess()) {
|
||||
sender.sendMessage(messages.get(sender, "broadcaster.deleted"));
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.error", "message", result.getMessage()));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.broadcaster.remove")
|
||||
))
|
||||
);
|
||||
|
||||
// Server
|
||||
manager.command(
|
||||
manager.commandBuilder("info")
|
||||
.permission("tze.server.info")
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
toozlie.showServerInfo(sender);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "server.info.commandDescription")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("schedule")
|
||||
.literal("listsupport")
|
||||
.permission("tze.schedule.listsupport")
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
toozlie.actionHelper.listSupport(sender);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.schedule.listsupport")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("schedule")
|
||||
.literal("list")
|
||||
.permission("tze.schedule.list")
|
||||
.optional("page", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
int page = ctx.getOrDefault("page", 1);
|
||||
toozlie.actionHelper.list(sender, page);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.schedule.list")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("schedule")
|
||||
.literal("delete")
|
||||
.permission("tze.schedule.delete")
|
||||
.required("index", IntegerParser.integerParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
int index = ctx.get("index");
|
||||
Toozlie.Result<Boolean> result = toozlie.actionHelper.removeAction(index - 1);
|
||||
if (result.isSuccess()) {
|
||||
sender.sendMessage(messages.get(sender, "action.deleted"));
|
||||
} else {
|
||||
sender.sendMessage(messages.get(sender, "generic.error", "message", result.getMessage()));
|
||||
}
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.schedule.delete")
|
||||
))
|
||||
);
|
||||
|
||||
manager.command(
|
||||
manager.commandBuilder("tze")
|
||||
.literal("schedule")
|
||||
.required("name", StringParser.stringParser())
|
||||
.required("action", StringParser.stringParser())
|
||||
.required("executeIntervalMinute", IntegerParser.integerParser())
|
||||
.optional("doOnce", BooleanParser.booleanParser())
|
||||
.optional("displayRemainingTime", BooleanParser.booleanParser())
|
||||
.optional("displayAtActionBar", BooleanParser.booleanParser())
|
||||
.handler(ctx -> {
|
||||
CommandSender sender = ctx.sender();
|
||||
String name = ctx.get("name");
|
||||
String action = ctx.get("action");
|
||||
int executeIntervalMinute = ctx.get("executeIntervalMinute");
|
||||
boolean doOnce = ctx.getOrDefault("doOnce", true);
|
||||
boolean displayRemainingTime = ctx.getOrDefault(
|
||||
"displayRemainingTime",
|
||||
defaultDisplayRemainingTime(action)
|
||||
);
|
||||
boolean displayAtActionBar = ctx.getOrDefault("displayAtActionBar", false);
|
||||
toozlie.actionHelper.addActionOnCommand(sender, name, action, executeIntervalMinute, doOnce,
|
||||
displayRemainingTime, displayAtActionBar);
|
||||
}).commandDescription(
|
||||
CommandDescription.commandDescription(
|
||||
messages.get(messages.defaultLocale(), "description.tze.schedule")
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private boolean ensureBroadcasterEnabled(CommandSender sender) {
|
||||
if (toozlie.broadcaster == null) {
|
||||
sender.sendMessage(messages.get(sender, "feature.broadcaster.disabled"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean defaultDisplayRemainingTime(String action) {
|
||||
return "action.server.stop".equals(action) || "action.item.clear".equals(action);
|
||||
}
|
||||
|
||||
private Component helpMessage(CommandSender sender, String key, Map<String, String> placeholders) {
|
||||
String message = messages.raw(sender, "cloud.help.minecraft." + key);
|
||||
if (message == null) {
|
||||
message = key;
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
|
||||
message = message.replace("<" + entry.getKey() + ">", entry.getValue());
|
||||
}
|
||||
|
||||
return Component.text(message);
|
||||
}
|
||||
|
||||
private boolean ensureDeathSaverEnabled(CommandSender sender) {
|
||||
if (toozlie.deathSaver == null) {
|
||||
sender.sendMessage(messages.get(sender, "feature.death-saver.disabled"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public Toozlie getToozlie() {
|
||||
return toozlie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.incendo.cloud.caption.Caption;
|
||||
import org.incendo.cloud.caption.CaptionProvider;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.MissingResourceException;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
public final class Messages {
|
||||
private static final Locale FALLBACK_LOCALE = Locale.US;
|
||||
private static final String BUNDLE_BASE_NAME = "messages";
|
||||
|
||||
private final Toozlie plugin;
|
||||
|
||||
public Messages(Toozlie plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public CaptionProvider<CommandSender> cloudCaptionProvider() {
|
||||
return (Caption caption, CommandSender sender) -> raw(sender, "cloud." + caption.key());
|
||||
}
|
||||
|
||||
public String get(CommandSender sender, String key, String... replacements) {
|
||||
return replace(raw(sender, key), replacements);
|
||||
}
|
||||
|
||||
public String get(Locale locale, String key, String... replacements) {
|
||||
return replace(raw(locale, key), replacements);
|
||||
}
|
||||
|
||||
public String raw(CommandSender sender, String key) {
|
||||
return raw(locale(sender), key);
|
||||
}
|
||||
|
||||
public String raw(Locale locale, String key) {
|
||||
ResourceBundle bundle = bundleOrNull(locale);
|
||||
if (bundle != null && bundle.containsKey(key)) {
|
||||
return bundle.getString(key);
|
||||
}
|
||||
|
||||
ResourceBundle defaultBundle = bundleOrNull(defaultLocale());
|
||||
if (defaultBundle != null && defaultBundle.containsKey(key)) {
|
||||
return defaultBundle.getString(key);
|
||||
}
|
||||
|
||||
ResourceBundle fallback = bundleOrNull(FALLBACK_LOCALE);
|
||||
return fallback != null && fallback.containsKey(key) ? fallback.getString(key) : null;
|
||||
}
|
||||
|
||||
public Locale defaultLocale() {
|
||||
return localeTag(plugin.config == null ? "en_US" : plugin.config.language);
|
||||
}
|
||||
|
||||
public Locale locale(CommandSender sender) {
|
||||
if (plugin.config == null || !"auto".equalsIgnoreCase(plugin.config.language)) {
|
||||
return defaultLocale();
|
||||
}
|
||||
|
||||
if (sender instanceof Player player) {
|
||||
return localeTag(player.getLocale());
|
||||
}
|
||||
|
||||
return defaultLocale();
|
||||
}
|
||||
|
||||
private ResourceBundle bundleOrNull(Locale locale) {
|
||||
Locale selected = locale == null ? FALLBACK_LOCALE : locale;
|
||||
try {
|
||||
return ResourceBundle.getBundle(
|
||||
BUNDLE_BASE_NAME,
|
||||
selected,
|
||||
ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_DEFAULT)
|
||||
);
|
||||
} catch (MissingResourceException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Locale localeTag(String tag) {
|
||||
if (tag == null || tag.isBlank() || "auto".equalsIgnoreCase(tag)) {
|
||||
return FALLBACK_LOCALE;
|
||||
}
|
||||
|
||||
String normalized = tag.replace('-', '_');
|
||||
String[] parts = normalized.split("_", 3);
|
||||
if (parts.length == 1) {
|
||||
return Locale.forLanguageTag(parts[0]);
|
||||
}
|
||||
if (parts.length == 2) {
|
||||
return new Locale(parts[0], parts[1]);
|
||||
}
|
||||
return new Locale(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
private String replace(String message, String... replacements) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String result = message;
|
||||
for (int i = 0; i + 1 < replacements.length; i += 2) {
|
||||
if (replacements[i] == null) {
|
||||
throw new RuntimeException("Message "+message+" replacements index are not correct.");
|
||||
}
|
||||
result = result.replace("{" + replacements[i] + "}", replacements[i + 1] == null ? "" : replacements[i + 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
import static java.lang.Math.abs;
|
||||
|
||||
public class PlayerEventListener implements Listener {
|
||||
private final Toozlie toozlie;
|
||||
|
||||
public PlayerEventListener(Toozlie plugin) {
|
||||
this.toozlie = plugin;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
|
||||
if (this.toozlie.config.enableRandomRespawn) {
|
||||
// Ensure doing below process if player don't have a bed
|
||||
if (player.getRespawnLocation() != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
World world = Bukkit.getWorld("world");
|
||||
|
||||
Random random = new Random();
|
||||
double r = abs(toozlie.config.radiusOfRandomRespawn);
|
||||
double centerX = toozlie.config.randomRespawnCenterX;
|
||||
double centerY = toozlie.config.randomRespawnCenterZ;
|
||||
|
||||
if (r == 0) {
|
||||
toozlie.getLogger().warning("Config \"radiusOfRandomRespawn\" is illegal. Failback to 10.");
|
||||
r = 10.0;
|
||||
}
|
||||
|
||||
double angle = random.nextDouble() * Math.PI * 2;
|
||||
double nr = Math.sqrt(random.nextDouble()) * abs(r);
|
||||
|
||||
double x = centerX + nr * Math.cos(angle);
|
||||
double z = centerY + nr * Math.sin(angle);
|
||||
|
||||
assert world != null;
|
||||
int y = world.getHighestBlockYAt((int) x, (int) z);
|
||||
|
||||
player.sendMessage(toozlie.messages.get(
|
||||
player,
|
||||
"respawn.random",
|
||||
"x",
|
||||
String.format("%.2f", x),
|
||||
"y",
|
||||
String.valueOf(y),
|
||||
"z",
|
||||
String.format("%.2f", z)
|
||||
));
|
||||
|
||||
Location respawn = new Location(world, x + 0.5, y + 1, z + 0.5);
|
||||
|
||||
event.setRespawnLocation(respawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import de.exlll.configlib.Configuration;
|
||||
import de.exlll.configlib.YamlConfigurations;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.incendo.cloud.execution.ExecutionCoordinator;
|
||||
import org.incendo.cloud.minecraft.extras.MinecraftHelp;
|
||||
import org.incendo.cloud.paper.LegacyPaperCommandManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
public final class Toozlie extends JavaPlugin {
|
||||
public globalConfig config;
|
||||
private LegacyPaperCommandManager<CommandSender> manager;
|
||||
private File configFile;
|
||||
private WhitelistPortal whitelistPortal;
|
||||
public DeathSaver deathSaver;
|
||||
public Broadcaster broadcaster;
|
||||
public ActionHelper actionHelper;
|
||||
public Messages messages;
|
||||
private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
|
||||
private static final Pattern LEGACY_HEX_COLOR = Pattern.compile("(?i)([&§])x(?:\\1[0-9a-f]){6}");
|
||||
private static final Pattern LEGACY_FORMAT_CODE = Pattern.compile("(?i)([&§])([0-9a-fk-or])");
|
||||
|
||||
public static class Result<T> {
|
||||
|
||||
private final boolean success;
|
||||
private final String message;
|
||||
private final T data;
|
||||
|
||||
public Result(boolean success, String message, T data) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public static <T> Result<T> success(T data) {
|
||||
return new Result<>(true, null, data);
|
||||
}
|
||||
|
||||
public static <T> Result<T> error(String message) {
|
||||
return new Result<>(false, message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private final List<String> requiredExpansions = List.of(
|
||||
"Player"
|
||||
);
|
||||
|
||||
private void checkExpansions() {
|
||||
if (!Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||
getLogger().warning("PlaceholderAPI not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (String expansion : requiredExpansions) {
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(),
|
||||
"papi ecloud download " + expansion
|
||||
);
|
||||
}
|
||||
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "papi reload");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
// Plugin startup logic
|
||||
|
||||
// Check dependencies
|
||||
if (!Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||
getLogger().severe("The PlaceholderAPI is not available!");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
} else {
|
||||
// Check PlaceholderAPI expansion
|
||||
checkExpansions();
|
||||
}
|
||||
|
||||
if (!Bukkit.getPluginManager().isPluginEnabled("ConfigLib")) {
|
||||
getLogger().severe("The configlib is not available!");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// init config
|
||||
configFile = new File(getDataFolder(), "config.yml");
|
||||
|
||||
this.config = YamlConfigurations.update(
|
||||
configFile.toPath(),
|
||||
globalConfig.class
|
||||
);
|
||||
this.messages = new Messages(this);
|
||||
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new JoinListener(this),
|
||||
this
|
||||
);
|
||||
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new PlayerEventListener(this),
|
||||
this
|
||||
);
|
||||
|
||||
// Register command
|
||||
manager = LegacyPaperCommandManager.createNative(this,
|
||||
ExecutionCoordinator.simpleCoordinator());
|
||||
registerCloudInternationalization();
|
||||
|
||||
registerCommands();
|
||||
|
||||
// Start whitelistPortal
|
||||
whitelistPortal = new WhitelistPortal(this);
|
||||
whitelistPortal.startWhitelist();
|
||||
|
||||
// Start DeathSaver
|
||||
if (config.enableDeathSaver) {
|
||||
deathSaver = new DeathSaver(this);
|
||||
deathSaver.startSaver();
|
||||
} else {
|
||||
getLogger().info("DeathSaver is disabled!");
|
||||
}
|
||||
|
||||
// Start Brocaster
|
||||
if (config.enableBroadcaster) {
|
||||
broadcaster = new Broadcaster(this);
|
||||
broadcaster.startBroadcaster();
|
||||
} else {
|
||||
getLogger().info("Broadcaster is disabled!");
|
||||
}
|
||||
|
||||
actionHelper = new ActionHelper(this);
|
||||
actionHelper.startActionScheduler();
|
||||
}
|
||||
|
||||
private void registerCommands() {
|
||||
new MainCommand(manager, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
// Plugin shutdown logic
|
||||
|
||||
// Save config
|
||||
if (this.configFile != null) {
|
||||
YamlConfigurations.save(
|
||||
configFile.toPath(),
|
||||
globalConfig.class,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
// Stop all workers
|
||||
whitelistPortal.stopWhitelist();
|
||||
|
||||
if (config.enableDeathSaver) {
|
||||
deathSaver.stopSaver();
|
||||
}
|
||||
|
||||
if (config.enableBroadcaster) {
|
||||
broadcaster.stopBroadcaster();
|
||||
}
|
||||
|
||||
if (actionHelper != null) {
|
||||
actionHelper.stopActionScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
whitelistPortal.stopWhitelist();
|
||||
|
||||
if (config.enableDeathSaver) {
|
||||
deathSaver.stopSaver();
|
||||
}
|
||||
|
||||
if (config.enableBroadcaster) {
|
||||
broadcaster.stopBroadcaster();
|
||||
}
|
||||
|
||||
if (actionHelper != null) {
|
||||
actionHelper.stopActionScheduler();
|
||||
}
|
||||
|
||||
config = YamlConfigurations.update(
|
||||
configFile.toPath(),
|
||||
globalConfig.class
|
||||
);
|
||||
this.messages = new Messages(this);
|
||||
|
||||
// Start whitelistPortal
|
||||
whitelistPortal = new WhitelistPortal(this);
|
||||
whitelistPortal.startWhitelist();
|
||||
|
||||
// Start DeathSaver
|
||||
if (config.enableDeathSaver) {
|
||||
deathSaver = new DeathSaver(this);
|
||||
deathSaver.startSaver();
|
||||
}
|
||||
|
||||
// Start Brocaster
|
||||
if (config.enableBroadcaster) {
|
||||
broadcaster = new Broadcaster(this);
|
||||
broadcaster.startBroadcaster();
|
||||
}
|
||||
|
||||
actionHelper = new ActionHelper(this);
|
||||
actionHelper.startActionScheduler();
|
||||
|
||||
getLogger().info("Reloaded!");
|
||||
}
|
||||
|
||||
public void saveConfig() {
|
||||
YamlConfigurations.save(configFile.toPath(), globalConfig.class, config);
|
||||
}
|
||||
|
||||
private void registerCloudInternationalization() {
|
||||
manager.captionRegistry().registerProvider(MinecraftHelp.defaultCaptionsProvider());
|
||||
manager.captionRegistry().registerProvider(messages.cloudCaptionProvider());
|
||||
}
|
||||
|
||||
@Configuration
|
||||
public static class globalConfig {
|
||||
public String language = "auto";
|
||||
public boolean showModifiedJoinMessage = true;
|
||||
public String welcomeMessage = "Hi! %player_name%";
|
||||
public String joinMessage = "Welcome back! %player_name%";
|
||||
public boolean enableRandomRespawn = false;
|
||||
public int radiusOfRandomRespawn = 20;
|
||||
public int randomRespawnCenterX = 0;
|
||||
public int randomRespawnCenterZ = 0;
|
||||
public boolean enableDeathSaver = false;
|
||||
public boolean enableBroadcaster = false;
|
||||
public whitelistPortal whitelistPortal = new whitelistPortal();
|
||||
public serverInfo serverInfo = new serverInfo();
|
||||
|
||||
@Configuration
|
||||
public static class whitelistPortal {
|
||||
public boolean enableWhitelistServer = false;
|
||||
public String host = "127.0.0.1";
|
||||
public int port = 1838;
|
||||
public auth auth = new auth();
|
||||
public security security = new security();
|
||||
|
||||
@Configuration
|
||||
public static class auth {
|
||||
public String mode = "token";
|
||||
public String token = "YOUR-TOKEN-HERE";
|
||||
public String hmacSecret = "YOUR-HMAC-SECRET-HERE";
|
||||
public int allowSkewSeconds = 30;
|
||||
}
|
||||
|
||||
@Configuration
|
||||
public static class security {
|
||||
public int maxRequestsPerMinute = 120;
|
||||
public int maxBodyBytes = 120;
|
||||
public String requireMethod = "POST";
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
public static class serverInfo {
|
||||
public String serverName = "Toozlie";
|
||||
public String description = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Some command functions
|
||||
public void showServerInfo(CommandSender p) {
|
||||
String description = config.serverInfo.description;
|
||||
String serverName = config.serverInfo.serverName;
|
||||
String joinedPlayerCount = Integer.toString(Bukkit.getOfflinePlayers().length);
|
||||
String onlinePlayerCount = Integer.toString(Bukkit.getOnlinePlayers().size());
|
||||
|
||||
if (description == null || description.isBlank()) {
|
||||
description = messages.get(p, "server.info.defaultDescription");
|
||||
}
|
||||
if (serverName == null || serverName.isBlank()) {
|
||||
serverName = getServer().getName();
|
||||
}
|
||||
|
||||
String message = String.format("%s\n%s\n%s\n%s\n%s\n%s",
|
||||
messages.get(p, "server.info.title", "serverName", serverName),
|
||||
messages.get(p, "server.info.version", "serverVersion", getServer().getVersion()),
|
||||
messages.get(p, "server.info.platform", "serverPlatform", getServer().getName()),
|
||||
messages.get(p, "server.info.onlinePlayerCount", "onlinePlayerCount", onlinePlayerCount),
|
||||
messages.get(p, "server.info.joinedPlayerCount", "joinedPlayerCount", joinedPlayerCount),
|
||||
description);
|
||||
|
||||
Component deserializeMessage = deserializeText(message);
|
||||
|
||||
p.sendMessage(deserializeMessage);
|
||||
}
|
||||
|
||||
public static Component deserializeText(String text) {
|
||||
return MINI_MESSAGE.deserialize(convertText(text));
|
||||
}
|
||||
|
||||
public static String convertText(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String converted = convertLegacyHexColors(text);
|
||||
Matcher matcher = LEGACY_FORMAT_CODE.matcher(converted);
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(toMiniMessageTag(matcher.group(2).charAt(0))));
|
||||
}
|
||||
|
||||
matcher.appendTail(result);
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String convertLegacyHexColors(String text) {
|
||||
Matcher matcher = LEGACY_HEX_COLOR.matcher(text);
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String legacyHex = matcher.group();
|
||||
String hex = legacyHex
|
||||
.replace("&", "")
|
||||
.replace("§", "")
|
||||
.substring(1)
|
||||
.toLowerCase(Locale.ROOT);
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement("<#" + hex + ">"));
|
||||
}
|
||||
|
||||
matcher.appendTail(result);
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String toMiniMessageTag(char legacyCode) {
|
||||
return switch (Character.toLowerCase(legacyCode)) {
|
||||
case '0' -> "<black>";
|
||||
case '1' -> "<dark_blue>";
|
||||
case '2' -> "<dark_green>";
|
||||
case '3' -> "<dark_aqua>";
|
||||
case '4' -> "<dark_red>";
|
||||
case '5' -> "<dark_purple>";
|
||||
case '6' -> "<gold>";
|
||||
case '7' -> "<gray>";
|
||||
case '8' -> "<dark_gray>";
|
||||
case '9' -> "<blue>";
|
||||
case 'a' -> "<green>";
|
||||
case 'b' -> "<aqua>";
|
||||
case 'c' -> "<red>";
|
||||
case 'd' -> "<light_purple>";
|
||||
case 'e' -> "<yellow>";
|
||||
case 'f' -> "<white>";
|
||||
case 'k' -> "<obfuscated>";
|
||||
case 'l' -> "<bold>";
|
||||
case 'm' -> "<strikethrough>";
|
||||
case 'n' -> "<underlined>";
|
||||
case 'o' -> "<italic>";
|
||||
case 'r' -> "<reset>";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package net.wei.toozlie;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
public final class WhitelistPortal {
|
||||
private HttpServer server;
|
||||
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger("Toozlie.WhitelistPortal");
|
||||
|
||||
// Very simple per-minute limiter (good enough for localhost automation)
|
||||
private final ConcurrentHashMap<String, AtomicInteger> minuteCounter = new ConcurrentHashMap<>();
|
||||
private volatile long currentMinute = System.currentTimeMillis() / 60000L;
|
||||
|
||||
private Object floodgateApi;
|
||||
|
||||
private final Toozlie toozlie;
|
||||
|
||||
// Config
|
||||
private File configFile;
|
||||
|
||||
public WhitelistPortal(Toozlie toozlie) {
|
||||
this.toozlie = toozlie;
|
||||
}
|
||||
|
||||
public static final List<String> SupportHTTPMethod = List.of("POST", "GET");
|
||||
|
||||
public void startWhitelist() {
|
||||
try {
|
||||
if (toozlie.config.whitelistPortal.enableWhitelistServer) {
|
||||
startHttp();
|
||||
logger.info("WhitelistPortal listening on http://{}:{}", toozlie.config.whitelistPortal.host, toozlie.config.whitelistPortal.port);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to start HTTP server: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
hookFloodgate();
|
||||
}
|
||||
|
||||
public void stopWhitelist() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private String readBodyOnce(HttpExchange ex) throws IOException {
|
||||
Object cached = ex.getAttribute("cachedBody");
|
||||
if (cached instanceof String) {
|
||||
return (String) cached;
|
||||
}
|
||||
|
||||
InputStream is = ex.getRequestBody();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[512];
|
||||
int r;
|
||||
int total = 0;
|
||||
|
||||
while ((r = is.read(buf)) != -1) {
|
||||
total += r;
|
||||
if (total > toozlie.config.whitelistPortal.security.maxBodyBytes) {
|
||||
throw new IOException("Request body too large");
|
||||
}
|
||||
baos.write(buf, 0, r);
|
||||
}
|
||||
|
||||
String body = baos.toString(StandardCharsets.UTF_8);
|
||||
ex.setAttribute("cachedBody", body);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void startHttp() throws IOException {
|
||||
InetSocketAddress addr = new InetSocketAddress(
|
||||
toozlie.config.whitelistPortal.host,
|
||||
toozlie.config.whitelistPortal.port);
|
||||
server = HttpServer.create(addr, 0);
|
||||
|
||||
server.createContext("/whitelist/on", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, () -> {
|
||||
Bukkit.setWhitelist(true);
|
||||
logger.info("Whitelist is enabled.");
|
||||
return jsonOk("whitelist enabled");
|
||||
})
|
||||
));
|
||||
|
||||
server.createContext("/whitelist/off", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, () -> {
|
||||
Bukkit.setWhitelist(false);
|
||||
logger.info("Whitelist is disabled.");
|
||||
return jsonOk("whitelist disabled");
|
||||
})
|
||||
));
|
||||
|
||||
server.createContext("/whitelist/add", ex -> handleAuthed(ex, () -> {
|
||||
String body = null;
|
||||
try {
|
||||
body = readBodyOnce(ex);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
Map<String, String> payload = parseTinyJson(body);
|
||||
runSyncAndRespond(ex, () -> {
|
||||
OfflinePlayer p = resolveOfflinePlayer(payload);
|
||||
if (p == null) return jsonFail("missing name or uuid");
|
||||
p.setWhitelisted(true);
|
||||
logger.info("Player " + display(p) + " is added to whitelist.");
|
||||
return jsonOk("whitelisted: " + display(p));
|
||||
});
|
||||
}));
|
||||
|
||||
server.createContext("/whitelist/remove", ex -> handleAuthed(ex, () -> {
|
||||
String body = null;
|
||||
try {
|
||||
body = readBodyOnce(ex);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
Map<String, String> payload = parseTinyJson(body);
|
||||
runSyncAndRespond(ex, () -> {
|
||||
OfflinePlayer p = resolveOfflinePlayer(payload);
|
||||
if (p == null) return jsonFail("missing name or uuid");
|
||||
p.setWhitelisted(false);
|
||||
logger.info("Player {} is removed from whitelist.", display(p));
|
||||
return jsonOk("removed from whitelist: " + display(p));
|
||||
});
|
||||
}));
|
||||
|
||||
server.createContext("/whitelist/list", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, () -> {
|
||||
Set<OfflinePlayer> set = Bukkit.getWhitelistedPlayers();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{\"ok\":true,\"players\":[");
|
||||
boolean first = true;
|
||||
for (OfflinePlayer p : set) {
|
||||
if (!first) sb.append(",");
|
||||
first = false;
|
||||
sb.append("{\"uuid\":\"").append(p.getUniqueId()).append("\",");
|
||||
sb.append("\"name\":").append(p.getName() == null ? "null" : "\"" + escape(p.getName()) + "\"").append("}");
|
||||
}
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
})
|
||||
));
|
||||
|
||||
server.createContext("/whitelist/status", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, this::getWhitelistStatus)
|
||||
));
|
||||
|
||||
server.createContext("/player/online", ex -> handleAuthed(ex, () ->
|
||||
runSyncAndRespond(ex, this::buildOnlinePlayersJson)
|
||||
));
|
||||
|
||||
// Thread pool for HTTP handling (Bukkit actions still run sync)
|
||||
server.setExecutor(Executors.newFixedThreadPool(4));
|
||||
server.start();
|
||||
}
|
||||
|
||||
private String getWhitelistStatus() {
|
||||
return "{\"ok\":true,\"whitelist\":\"" +
|
||||
Bukkit.hasWhitelist() + "\"}";
|
||||
}
|
||||
|
||||
private void hookFloodgate() {
|
||||
try {
|
||||
if (!Bukkit.getPluginManager().isPluginEnabled("floodgate")) {
|
||||
floodgateApi = null;
|
||||
logger.info("Floodgate API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi");
|
||||
floodgateApi = apiClass.getMethod("getInstance").invoke(null);
|
||||
logger.info("Floodgate API hooked via FloodgateApi.getInstance()");
|
||||
} catch (Throwable e) {
|
||||
floodgateApi = null;
|
||||
logger.info("Floodgate API not available");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildOnlinePlayersJson() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{\"ok\":true,\"players\":[");
|
||||
|
||||
boolean first = true;
|
||||
for (var p : Bukkit.getOnlinePlayers()) {
|
||||
if (!first) sb.append(",");
|
||||
first = false;
|
||||
|
||||
UUID uuid = p.getUniqueId();
|
||||
boolean isFloodgate = isFloodgatePlayer(uuid);
|
||||
|
||||
sb.append("{");
|
||||
sb.append("\"name\":\"").append(escape(p.getName())).append("\",");
|
||||
sb.append("\"uuid\":\"").append(uuid).append("\",");
|
||||
sb.append("\"isFloodgate\":").append(isFloodgate).append(",");
|
||||
|
||||
if (isFloodgate) {
|
||||
sb.append("\"floodgateUuid\":\"").append(uuid).append("\"");
|
||||
} else {
|
||||
sb.append("\"floodgateUuid\":null");
|
||||
}
|
||||
|
||||
sb.append("}");
|
||||
}
|
||||
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private boolean isFloodgatePlayer(UUID uuid) {
|
||||
if (floodgateApi == null) return false;
|
||||
if (Bukkit.getPlayer(uuid) == null) return false;
|
||||
|
||||
try {
|
||||
return (boolean) floodgateApi.getClass()
|
||||
.getMethod("isFloodgatePlayer", UUID.class)
|
||||
.invoke(floodgateApi, uuid);
|
||||
} catch (Throwable e) {
|
||||
logger.debug("Unable to check Floodgate player {}: {}", uuid, e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getFloodgateXuid(UUID uuid) {
|
||||
if (floodgateApi == null) return null;
|
||||
if (Bukkit.getPlayer(uuid) == null) return null;
|
||||
|
||||
try {
|
||||
Object floodgatePlayer = floodgateApi.getClass()
|
||||
.getMethod("getPlayer", UUID.class)
|
||||
.invoke(floodgateApi, uuid);
|
||||
|
||||
if (floodgatePlayer == null) return null;
|
||||
|
||||
// FloodgatePlayer#getXuid()
|
||||
return (String) floodgatePlayer.getClass()
|
||||
.getMethod("getXuid")
|
||||
.invoke(floodgatePlayer);
|
||||
|
||||
} catch (Throwable e) {
|
||||
logger.debug("Unable to get Floodgate XUID for {}: {}", uuid, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleAuthed(HttpExchange ex, Runnable authedAction) throws IOException {
|
||||
try {
|
||||
// Method restriction
|
||||
if (!toozlie.config.whitelistPortal.security.requireMethod.equalsIgnoreCase(ex.getRequestMethod())) {
|
||||
writeJson(ex, 405, jsonFail("method not allowed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit per minute per IP (localhost typically)
|
||||
if (!allowRequest(ex)) {
|
||||
writeJson(ex, 429, jsonFail("rate limited"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic path sanity
|
||||
if (!isAuthed(ex)) {
|
||||
writeJson(ex, 401, jsonFail("unauthorized"));
|
||||
return;
|
||||
}
|
||||
|
||||
authedAction.run();
|
||||
} catch (Exception e) {
|
||||
logger.warn("HTTP error: {}", e.getMessage());
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allowRequest(HttpExchange ex) {
|
||||
long minute = System.currentTimeMillis() / 60000L;
|
||||
if (minute != currentMinute) {
|
||||
currentMinute = minute;
|
||||
minuteCounter.clear();
|
||||
}
|
||||
String key = ex.getRemoteAddress().getAddress().getHostAddress();
|
||||
AtomicInteger c = minuteCounter.computeIfAbsent(key, k -> new AtomicInteger(0));
|
||||
return c.incrementAndGet() <= toozlie.config.whitelistPortal.security.maxRequestsPerMinute;
|
||||
}
|
||||
|
||||
private boolean isAuthed(HttpExchange ex) {
|
||||
Headers h = ex.getRequestHeaders();
|
||||
|
||||
if ("token".equals(toozlie.config.whitelistPortal.auth.mode)) {
|
||||
String auth = h.getFirst("Authorization");
|
||||
if (auth == null) return false;
|
||||
String prefix = "Bearer ";
|
||||
if (!auth.startsWith(prefix)) return false;
|
||||
String got = auth.substring(prefix.length()).trim();
|
||||
return constantTimeEquals(got, toozlie.config.whitelistPortal.auth.token);
|
||||
}
|
||||
|
||||
if ("hmac".equals(toozlie.config.whitelistPortal.auth.mode)) {
|
||||
String sig = h.getFirst("X-Signature");
|
||||
String ts = h.getFirst("X-Timestamp");
|
||||
if (sig == null || ts == null) return false;
|
||||
|
||||
long now = Instant.now().getEpochSecond();
|
||||
long t;
|
||||
try { t = Long.parseLong(ts); } catch (Exception e) { return false; }
|
||||
if (Math.abs(now - t) > toozlie.config.whitelistPortal.auth.allowSkewSeconds) return false;
|
||||
|
||||
// Signature over: method + "\n" + path + "\n" + timestamp + "\n" + body
|
||||
String body = "";
|
||||
try {
|
||||
body = readBody(ex); // consumes stream; store in attribute for later usage
|
||||
ex.setAttribute("cachedBody", body);
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
String data = ex.getRequestMethod() + "\n" + ex.getRequestURI().getPath() + "\n" + ts + "\n" + body;
|
||||
String expected = hmacHex(toozlie.config.whitelistPortal.auth.hmacSecret, data);
|
||||
return constantTimeEquals(sig.toLowerCase(Locale.ROOT), expected.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
// Unknown mode -> deny
|
||||
return false;
|
||||
}
|
||||
|
||||
private String readBody(HttpExchange ex) throws IOException {
|
||||
try (InputStream is = ex.getRequestBody()) {
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private void runSyncAndRespond(HttpExchange ex, Callable<String> action) {
|
||||
try {
|
||||
// Run Bukkit actions on the main server thread and wait for the result.
|
||||
Future<String> future = Bukkit.getScheduler().callSyncMethod(toozlie, action);
|
||||
|
||||
String json;
|
||||
try {
|
||||
json = future.get();
|
||||
} catch (ExecutionException ee) {
|
||||
logger.error("Sync task error: {}", String.valueOf(ee.getCause()));
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(ex, 200, json);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("HTTP handler error");
|
||||
e.printStackTrace();
|
||||
|
||||
try {
|
||||
writeJson(ex, 500, jsonFail("internal error"));
|
||||
} catch (IOException io) {
|
||||
io.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeJson(HttpExchange ex, int code, String json) throws IOException {
|
||||
byte[] data = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Headers h = ex.getResponseHeaders();
|
||||
h.set("Content-Type", "application/json; charset=utf-8");
|
||||
h.set("Cache-Control", "no-store");
|
||||
h.set("Connection", "close");
|
||||
|
||||
ex.sendResponseHeaders(code, data.length);
|
||||
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(data);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
ex.close();
|
||||
}
|
||||
|
||||
private OfflinePlayer resolveOfflinePlayer(Map<String, String> payload) {
|
||||
|
||||
String floodgateUuid = payload.get("floodgateUuid");
|
||||
if (floodgateUuid != null && !floodgateUuid.isBlank()) {
|
||||
try {
|
||||
UUID id = UUID.fromString(floodgateUuid.trim());
|
||||
return Bukkit.getOfflinePlayer(id);
|
||||
} catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
|
||||
String uuid = payload.get("uuid");
|
||||
if (uuid != null && !uuid.isBlank()) {
|
||||
try {
|
||||
UUID id = UUID.fromString(uuid.trim());
|
||||
return Bukkit.getOfflinePlayer(id);
|
||||
} catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
|
||||
String name = payload.get("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
|
||||
for (var p : Bukkit.getOnlinePlayers()) {
|
||||
if (p.getName().equalsIgnoreCase(name.trim())) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return Bukkit.getOfflinePlayer(name.trim());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Tiny JSON parser for {"name":"x"} / {"uuid":"..."} only
|
||||
private static final Pattern KV = Pattern.compile("\"(floodgateUuid|uuid|name)\"\\s*:\\s*\"([^\"]*)\"");
|
||||
private Map<String, String> parseTinyJson(String body) {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
if (body == null) return m;
|
||||
Matcher matcher = KV.matcher(body);
|
||||
while (matcher.find()) {
|
||||
m.put(matcher.group(1), matcher.group(2));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private String jsonOk(String message) {
|
||||
return "{\"ok\":true,\"message\":\"" + escape(message) + "\"}";
|
||||
}
|
||||
|
||||
private String jsonFail(String message) {
|
||||
return "{\"ok\":false,\"message\":\"" + escape(message) + "\"}";
|
||||
}
|
||||
|
||||
private String display(OfflinePlayer p) {
|
||||
String name = p.getName();
|
||||
return (name == null ? "null" : name) + " (" + p.getUniqueId() + ")";
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null) return false;
|
||||
byte[] x = a.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] y = b.getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(x, y);
|
||||
}
|
||||
|
||||
private static String hmacHex(String secret, String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] out = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : out) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
command.reload=§aReloaded.
|
||||
command.version=§eVersion: {version}
|
||||
action.scheduled=§aScheduled {action}. Mode: {mode}. Time: {minutes} minute(s). Display remaining time: {displayRemainingTime}.
|
||||
action.started=§eRunning scheduled action {name}
|
||||
action.server.stop.countdown=§cServer will stop in §e{time}.§c
|
||||
action.item.clear.countdown=§eDropped items will be cleared in §f{time}.§e
|
||||
action.item.clear.completed=§aDropped item cleanup completed. Removed §f{count}§a item(s).
|
||||
action.none=§7No scheduled actions exist.
|
||||
action.header=§dScheduled Actions §7(page {page}/{maxPage}, total {total})
|
||||
action.entry=<yellow>#{index}: {action}</yellow> <gray>name={name}, mode={mode}, next={nextTime}, interval={interval}, displayRemainingTime={displayRemainingTime}</gray>
|
||||
action.deleted=§aScheduled action deleted.
|
||||
action.support.header=§dSupported Actions §7(total {total})
|
||||
action.support.entry=<yellow>- {action}</yellow>
|
||||
action.unsupported=§cUnsupported action: {action}
|
||||
action.invalid-time=§cStart time must be 0 or greater.
|
||||
action.invalid-interval=§cRepeat interval must be at least {minutes} minute(s).
|
||||
generic.players-only=§cPlayers only.
|
||||
generic.no-permission=§cYou do not have permission to use this command.
|
||||
generic.error=§cError: {message}
|
||||
feature.broadcaster.disabled=§cBroadcaster is disabled.
|
||||
feature.death-saver.disabled=§cDeathSaver is disabled.
|
||||
death.cleared=§eYour death points have been cleared.
|
||||
death.none=§7No death points recorded.
|
||||
death.header=§dDeathPositions §7(page {page}/{maxPage}, total {total})
|
||||
death.tip=§dTip: /deathpos tp <index> §7(requires teleport permission)
|
||||
death.entry=§e#{index}§7 [{time}] §b{world}§f {xyz}§8{message}
|
||||
death.entry.message= - {message}
|
||||
death.invalid-index=§cInvalid index. Use /deathpos list.
|
||||
death.world-not-loaded=§cWorld not loaded: {world}
|
||||
death.teleported=§aTeleported to death point #{index}.
|
||||
death.saved=§eYou died at X:{x} Y:{y} Z:{z}
|
||||
death.usage=§cUsage: /deathpos [list [page]|last|tp <index>|clear]
|
||||
death.usage.tp=§cUsage: /deathpos tp <index>
|
||||
broadcaster.none=§7No broadcast messages exist.
|
||||
broadcaster.header=§dBroadcasts §7(page {page}/{maxPage}, total {total})
|
||||
broadcaster.entry=<yellow>#{index}:{name}:</yellow> {content} <green>(Created at: {time})</green>
|
||||
broadcaster.added=§eMessage {name} added.
|
||||
broadcaster.deleted=§aDeleted.
|
||||
respawn.random=§7Random respawn: X:{x} Y:{y} Z:{z}
|
||||
description.tze.reload=Reload plugin.
|
||||
description.tze.version=Show Toozlie version.
|
||||
description.tze.schedule=Schedule a supported action.
|
||||
description.tze.schedule.list=List scheduled actions.
|
||||
description.tze.schedule.delete=Delete a scheduled action.
|
||||
description.tze.schedule.listsupport=List supported schedule actions.
|
||||
description.help=List available commands for Toozlie.
|
||||
description.deathpos.list=List your death records.
|
||||
description.deathpos.latest=Get your latest death record.
|
||||
description.deathpos.tp=Teleport to a death record coordinate.
|
||||
description.deathpos.clear=Clear death records.
|
||||
description.broadcaster.list=List broadcast messages.
|
||||
description.broadcaster.add=Add a broadcast message.
|
||||
description.broadcaster.remove=Remove a broadcast message.
|
||||
server.info.title=§e{serverName}
|
||||
server.info.version=§7Version: §f{serverVersion}
|
||||
server.info.platform=§7Platform: §f{serverPlatform}
|
||||
server.info.onlinePlayerCount=§7Online players: §f{onlinePlayerCount}
|
||||
server.info.joinedPlayerCount=§7Known players: §f{joinedPlayerCount}
|
||||
server.info.defaultDescription=§7Write something about this server...
|
||||
server.info.commandDescription=Get server information.
|
||||
cloud.argument.parse.failure.boolean=Could not parse boolean from '<input>'.
|
||||
cloud.argument.parse.failure.number='<input>' is not a valid number in the range <min> to <max>.
|
||||
cloud.argument.parse.failure.char='<input>' is not a valid character.
|
||||
cloud.argument.parse.failure.string='<input>' is not a valid string of type <stringMode>.
|
||||
cloud.argument.parse.failure.uuid='<input>' is not a valid UUID.
|
||||
cloud.argument.parse.failure.enum='<input>' is not one of the following: <acceptableValues>.
|
||||
cloud.argument.parse.failure.regex='<input>' does not match '<pattern>'.
|
||||
cloud.argument.parse.failure.flag.unknown=Unknown flag '<flag>'.
|
||||
cloud.argument.parse.failure.flag.duplicate_flag=Duplicate flag '<flag>'.
|
||||
cloud.argument.parse.failure.flag.no_flag_started=No flag started. Cannot handle '<input>'.
|
||||
cloud.argument.parse.failure.flag.missing_argument=Missing argument for '<flag>'.
|
||||
cloud.argument.parse.failure.flag.no_permission=You do not have permission to use '<flag>'.
|
||||
cloud.argument.parse.failure.color='<input>' is not a valid color.
|
||||
cloud.argument.parse.failure.duration='<input>' is not a duration format.
|
||||
cloud.argument.parse.failure.aggregate.missing=Missing component '<component>'.
|
||||
cloud.argument.parse.failure.aggregate.failure=Invalid component '<component>': <failure>.
|
||||
cloud.argument.parse.failure.either=Could not resolve <primary> or <fallback> from '<input>'.
|
||||
cloud.exception.unexpected=An internal error occurred while attempting to perform this command.
|
||||
cloud.exception.invalid_argument=Invalid command argument: <cause>.
|
||||
cloud.exception.no_such_command=Unknown command.
|
||||
cloud.exception.no_permission=You do not have permission to perform this command.
|
||||
cloud.exception.invalid_sender=<actual> is not allowed to execute that command. Must be of type <expected>.
|
||||
cloud.exception.invalid_sender_list=<actual> is not allowed to execute that command. Must be one of <expected>.
|
||||
cloud.exception.invalid_syntax=Invalid command syntax. Correct command syntax is: <syntax>.
|
||||
cloud.help.minecraft.help=Help
|
||||
cloud.help.minecraft.command=Command
|
||||
cloud.help.minecraft.description=Description
|
||||
cloud.help.minecraft.no_description=No description
|
||||
cloud.help.minecraft.arguments=Arguments
|
||||
cloud.help.minecraft.optional=Optional
|
||||
cloud.help.minecraft.showing_results_for_query=Showing search results for query
|
||||
cloud.help.minecraft.no_results_for_query=No results for query
|
||||
cloud.help.minecraft.available_commands=Available Commands
|
||||
cloud.help.minecraft.click_to_show_help=Click to show help for this command
|
||||
cloud.help.minecraft.page_out_of_range=Error: Page <page> is not in range. Must be in range [1, <max_pages>]
|
||||
cloud.help.minecraft.click_for_next_page=Click for next page
|
||||
cloud.help.minecraft.click_for_previous_page=Click for previous page
|
||||
@@ -0,0 +1,97 @@
|
||||
command.reload=§a已重新載入。
|
||||
command.version=§e版本:{version}
|
||||
generic.players-only=§c此指令僅供玩家使用。
|
||||
generic.no-permission=§c你沒有權限使用此指令。
|
||||
generic.error=§c錯誤:{message}
|
||||
feature.broadcaster.disabled=§cBroadcaster 目前未啟用。
|
||||
feature.death-saver.disabled=§cDeathSaver 目前未啟用。
|
||||
death.cleared=§e你的死亡點已清除。
|
||||
death.none=§7尚無死亡紀錄。
|
||||
death.header=§d死亡點 §7(第 {page}/{maxPage} 頁,共 {total} 筆)
|
||||
death.tip=§d提示:/deathpos tp <索引> §7(需要傳送權限)
|
||||
death.entry=§e#{index}§7 [{time}] §b{world}§f {xyz}§8{message}
|
||||
death.entry.message= - {message}
|
||||
death.invalid-index=§c未知的死亡點索引,請使用 /deathpos list 查看紀錄。
|
||||
death.world-not-loaded=§c世界尚未載入:{world}
|
||||
death.teleported=§a已傳送到死亡點 #{index}。
|
||||
death.saved=§e你死在 X:{x} Y:{y} Z:{z}
|
||||
death.usage=§c用法:/deathpos [list [頁數]|last|tp <索引>|clear]
|
||||
death.usage.tp=§c用法:/deathpos tp <索引>
|
||||
broadcaster.none=§7尚無廣播訊息。
|
||||
broadcaster.header=§d廣播 §7(第 {page}/{maxPage} 頁,共 {total} 筆)
|
||||
broadcaster.entry=<yellow>#{index}:{name}:</yellow> {content} <green>(建立時間:{time})</green>
|
||||
broadcaster.added=§e已新增訊息 {name}。
|
||||
broadcaster.deleted=§a已刪除。
|
||||
respawn.random=§7隨機重生點:X:{x} Y:{y} Z:{z}
|
||||
description.tze.reload=重新載入插件。
|
||||
description.tze.version=顯示 Toozlie 版本。
|
||||
description.help=列出 Toozlie 可用指令。
|
||||
description.deathpos.list=列出你的死亡紀錄。
|
||||
description.deathpos.latest=取得最近一次死亡紀錄。
|
||||
description.deathpos.tp=傳送到指定死亡點座標。
|
||||
description.deathpos.clear=清除死亡紀錄。
|
||||
description.broadcaster.list=列出廣播訊息。
|
||||
description.broadcaster.add=新增廣播訊息。
|
||||
description.broadcaster.remove=移除廣播訊息。
|
||||
server.info.title=§e{serverName}
|
||||
server.info.version=§7版本:§f{serverVersion}
|
||||
server.info.platform=§7平台:§f{serverPlatform}
|
||||
server.info.onlinePlayerCount=§7線上玩家:§f{onlinePlayerCount}
|
||||
server.info.joinedPlayerCount=§7已知玩家:§f{joinedPlayerCount}
|
||||
server.info.defaultDescription=§7在 config.yml 裡替這個伺服器寫一段介紹吧。
|
||||
server.info.commandDescription=取得伺服器資訊。
|
||||
cloud.argument.parse.failure.boolean=無法將 '<input>' 解析為布林值。
|
||||
cloud.argument.parse.failure.number='<input>' 不是介於 <min> 到 <max> 的有效數字。
|
||||
cloud.argument.parse.failure.char='<input>' 不是有效字元。
|
||||
cloud.argument.parse.failure.string='<input>' 不是有效的 <stringMode> 字串。
|
||||
cloud.argument.parse.failure.uuid='<input>' 不是有效 UUID。
|
||||
cloud.argument.parse.failure.enum='<input>' 不是以下任一值:<acceptableValues>。
|
||||
cloud.argument.parse.failure.regex='<input>' 不符合 '<pattern>'。
|
||||
cloud.argument.parse.failure.flag.unknown=未知旗標 '<flag>'。
|
||||
cloud.argument.parse.failure.flag.duplicate_flag=重複旗標 '<flag>'。
|
||||
cloud.argument.parse.failure.flag.no_flag_started=尚未開始旗標,無法處理 '<input>'。
|
||||
cloud.argument.parse.failure.flag.missing_argument=旗標 '<flag>' 缺少參數。
|
||||
cloud.argument.parse.failure.flag.no_permission=你沒有權限使用 '<flag>'。
|
||||
cloud.argument.parse.failure.color='<input>' 不是有效顏色。
|
||||
cloud.argument.parse.failure.duration='<input>' 不是有效時間長度格式。
|
||||
cloud.argument.parse.failure.aggregate.missing=缺少元件 '<component>'。
|
||||
cloud.argument.parse.failure.aggregate.failure=無效元件 '<component>':<failure>。
|
||||
cloud.argument.parse.failure.either=無法從 '<input>' 解析 <primary> 或 <fallback>。
|
||||
cloud.exception.unexpected=執行指令時發生內部錯誤。
|
||||
cloud.exception.invalid_argument=無效的指令參數:<cause>。
|
||||
cloud.exception.no_such_command=未知指令。
|
||||
cloud.exception.no_permission=你沒有權限執行此指令。
|
||||
cloud.exception.invalid_sender=<actual> 不能執行此指令,必須是 <expected>。
|
||||
cloud.exception.invalid_sender_list=<actual> 不能執行此指令,必須是以下其中之一:<expected>。
|
||||
cloud.exception.invalid_syntax=指令語法錯誤。正確語法:<syntax>。
|
||||
cloud.help.minecraft.help=說明
|
||||
cloud.help.minecraft.command=指令
|
||||
cloud.help.minecraft.description=描述
|
||||
cloud.help.minecraft.no_description=無描述
|
||||
cloud.help.minecraft.arguments=參數
|
||||
cloud.help.minecraft.optional=選填
|
||||
cloud.help.minecraft.showing_results_for_query=搜尋結果
|
||||
cloud.help.minecraft.no_results_for_query=找不到符合的指令
|
||||
cloud.help.minecraft.available_commands=可用指令
|
||||
cloud.help.minecraft.click_to_show_help=點擊查看此指令說明
|
||||
cloud.help.minecraft.page_out_of_range=錯誤:第 <page> 頁超出範圍,必須介於 1 到 <max_pages>
|
||||
cloud.help.minecraft.click_for_next_page=下一頁
|
||||
cloud.help.minecraft.click_for_previous_page=上一頁
|
||||
action.scheduled=§a已排程 {action}。模式:{mode}。時間:{minutes} 分鐘。顯示剩餘時間:{displayRemainingTime}。
|
||||
action.started=§e正在執行排程動作 {name}
|
||||
action.server.stop.countdown=§c伺服器將在 §e{time} §c後關閉。
|
||||
action.item.clear.countdown=§e掉落物將在 §f{time} §e後清理。
|
||||
action.item.clear.completed=§a掉落物清理完成,共移除 §f{count} §a個物品。
|
||||
action.none=§7目前沒有排程動作。
|
||||
action.header=§d排程動作 §7(第 {page}/{maxPage} 頁,共 {total} 筆)
|
||||
action.entry=<yellow>#{index}: {action}</yellow> <gray>名稱={name}, 模式={mode}, 下次={nextTime}, 間隔={interval}, 顯示剩餘時間={displayRemainingTime}</gray>
|
||||
action.deleted=§a已刪除排程動作。
|
||||
action.support.header=§d可用排程動作 §7(共 {total} 個)
|
||||
action.support.entry=<yellow>- {action}</yellow>
|
||||
action.unsupported=§c不支援的動作:{action}
|
||||
action.invalid-time=§c開始時間必須大於或等於 0。
|
||||
action.invalid-interval=§c重複執行間隔至少需要 {minutes} 分鐘。
|
||||
description.tze.schedule=排程執行支援的動作。
|
||||
description.tze.schedule.list=列出排程動作。
|
||||
description.tze.schedule.delete=刪除排程動作。
|
||||
description.tze.schedule.listsupport=列出可用的排程動作。
|
||||
@@ -0,0 +1,6 @@
|
||||
name: Toozlie
|
||||
version: '1.0-dev'
|
||||
main: net.wei.toozlie.Toozlie
|
||||
api-version: '1.21'
|
||||
depend: [PlaceholderAPI, ConfigLib]
|
||||
softdepend: [Floodgate]
|
||||
Reference in New Issue
Block a user