Update files.

This commit is contained in:
2026-06-27 15:44:37 +08:00
commit a16715d9f4
20 changed files with 3670 additions and 0 deletions
View File
+89
View File
@@ -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
}
}
View File
Vendored
+249
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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;
}
}
+112
View File
@@ -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);
}
}
}
+383
View File
@@ -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=列出可用的排程動作。
+6
View File
@@ -0,0 +1,6 @@
name: Toozlie
version: '1.0-dev'
main: net.wei.toozlie.Toozlie
api-version: '1.21'
depend: [PlaceholderAPI, ConfigLib]
softdepend: [Floodgate]