first commit

This commit is contained in:
2025-11-08 01:06:34 +01:00
commit 8118646c63
24 changed files with 1730 additions and 0 deletions

119
.gitignore vendored Normal file
View File

@@ -0,0 +1,119 @@
# User-specific stuff
.idea/
*.iml
*.ipr
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
.gradle
build/
# Ignore Gradle GUI config
gradle-app.setting
# Cache of project
.gradletasknamecache
**/build/
# Common working directory
run/
runs/
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar

55
build.gradle Normal file
View File

@@ -0,0 +1,55 @@
plugins {
id 'java'
id("xyz.jpenilla.run-paper") version "2.3.1"
}
group = 'me.monster'
version = '1.0'
repositories {
mavenCentral()
maven {
name = "papermc-repo"
url = "https://repo.papermc.io/repository/maven-public/"
}
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
}
tasks {
runServer {
// 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")
}
}
def targetJavaVersion = 21
java {
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
if (JavaVersion.current() < javaVersion) {
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
options.release.set(targetJavaVersion)
}
}
processResources {
def props = [version: version]
inputs.properties props
filteringCharset 'UTF-8'
filesMatching('plugin.yml') {
expand props
}
}

0
gradle.properties Normal file
View File

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
gradlew vendored Normal file
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" "$@"

92
gradlew.bat vendored Normal file
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
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'redmc-API'

View File

@@ -0,0 +1,18 @@
package me.monster;
import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin {
@Override
public void onEnable() {
getLogger().info("Api Loaded");
// Plugin startup logic
}
@Override
public void onDisable() {
// Plugin shutdown logic
}
}

View File

@@ -0,0 +1,56 @@
package me.monster.colors;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TextComponent;
import org.jetbrains.annotations.NotNull;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ColorUtils {
public static final String WITH_DELIMITER = "((?<=%1$s)|(?=%1$s))";
private static final Pattern HEX_PATTERN = Pattern.compile("(&#[0-9a-fA-F]{6})");
/**
* @param text The string of text to apply color/effects to
* @return Returns a string of text with color/effects applied
*/
public static String translateColorCodes(@NotNull String text) {
//good thing we're stuck on java 8, which means we can't use this (:
// String hexColored = HEX_PATTERN.matcher(text)
// .replaceAll(match -> "" + ChatColor.of(match.group(1)));
Matcher matcher = HEX_PATTERN.matcher(text);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String hex = matcher.group(1).substring(1);
matcher.appendReplacement(sb, "" + ChatColor.of(hex));
}
matcher.appendTail(sb);
String hexColored = sb.toString();
return ChatColor.translateAlternateColorCodes('&', hexColored);
}
/**
* @param text The text with color codes that you want to turn into a TextComponent
* @return the TextComponent with hex colors and regular colors
*/
public static TextComponent translateColorCodesToTextComponent(@NotNull String text) {
//This is done solely to ensure hex color codes are in the format
//fromLegacyText expects:
//&#FF0000 -> &x&f&f&0&0&0&0
String colored = translateColorCodes(text);
TextComponent base = new TextComponent();
BaseComponent[] converted = TextComponent.fromLegacyText(colored);
for (BaseComponent comp : converted) {
base.addExtra(comp);
}
return base;
}
}

View File

@@ -0,0 +1,19 @@
package me.monster.commands;
import org.bukkit.command.CommandSender;
import java.util.List;
/**
* A functional interface used to allow the dev to specify how the listing of the subcommands on a core command works.
*/
@FunctionalInterface
public interface CommandList {
/**
* @param sender The thing that ran the command
* @param subCommandList A list of all the subcommands you can display
*/
void displayCommandList(CommandSender sender, List<SubCommand> subCommandList);
}

View File

@@ -0,0 +1,71 @@
package me.monster.commands;
import org.bukkit.command.CommandMap;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class CommandManager {
/**
* @param plugin An instance of your plugin that is using this API. If called within plugin main class, provide this keyword
* @param commandName The name of the command
* @param commandDescription Description of command as would put it in plugin.yml
* @param commandUsage Usage of command as would put it in plugin.yml
* @param aliases A String list of aliases(or nothing for overloaded method)
* @param subcommands Class reference to each SubCommand you create for this core command
*/
@SafeVarargs
public static void createCoreCommand(JavaPlugin plugin,
String commandName,
String commandDescription,
String commandUsage,
@Nullable CommandList commandList,
List<String> aliases,
Class<? extends SubCommand>... subcommands) throws NoSuchFieldException, IllegalAccessException {
ArrayList<SubCommand> commands = new ArrayList<>();
Arrays.stream(subcommands).map(subcommand -> {
try {
Constructor<? extends SubCommand> constructor = subcommand.getConstructor();
return constructor.newInstance();
} catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
e.printStackTrace();
}
return null;
}).forEach(commands::add);
//THANK YOU OZZYMAR <3 YOU'RE THE HOMIE
Field commandField = plugin.getServer().getClass().getDeclaredField("commandMap");
commandField.setAccessible(true);
CommandMap commandMap = (CommandMap) commandField.get(plugin.getServer());
commandMap.register(commandName, new CoreCommand(commandName, commandDescription, commandUsage, commandList, aliases, commands));
}
/**
* @param plugin An instance of your plugin that is using this API. If called within plugin main class, provide this keyword
* @param commandName The name of the command
* @param commandDescription Description of command as would put it in plugin.yml
* @param commandUsage Usage of command as would put it in plugin.yml
* @param subcommands Class reference to each SubCommand you create for this core command
*/
@SafeVarargs
public static void createCoreCommand(JavaPlugin plugin,
String commandName,
String commandDescription,
String commandUsage,
@Nullable CommandList commandList,
Class<? extends SubCommand>... subcommands) throws NoSuchFieldException, IllegalAccessException {
createCoreCommand(plugin, commandName, commandDescription, commandUsage, commandList, Collections.singletonList(""), subcommands);
}
}

View File

@@ -0,0 +1,84 @@
package me.monster.commands;
import me.monster.colors.ColorUtils;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class CoreCommand extends Command {
private final ArrayList<SubCommand> subcommands;
private final CommandList commandList;
public CoreCommand(String name, String description, String usageMessage, CommandList commandList, List<String> aliases, ArrayList<SubCommand> subCommands) {
super(name, description, usageMessage, aliases);
//Get the subcommands so we can access them in the command manager class(here)
this.subcommands = subCommands;
this.commandList = commandList;
}
public ArrayList<SubCommand> getSubCommands() {
return subcommands;
}
@Override
public boolean execute(@NotNull CommandSender sender, @NotNull String commandLabel, String[] args) {
if (sender.hasPermission("redmc.manage")) {
if (args.length > 0) {
for (int i = 0; i < getSubCommands().size(); i++) {
if (args[0].equalsIgnoreCase(getSubCommands().get(i).getName()) || (getSubCommands().get(i).getAliases() != null && getSubCommands().get(i).getAliases().contains(args[0]))) {
getSubCommands().get(i).perform(sender, args);
}
}
} else {
if (commandList == null) {
sender.sendMessage(ColorUtils.translateColorCodes("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-"));
for (SubCommand subcommand : subcommands) {
sender.sendMessage(ColorUtils.translateColorCodes(subcommand.getSyntax() + " - " + subcommand.getDescription()));
}
sender.sendMessage(ColorUtils.translateColorCodes("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-"));
} else {
commandList.displayCommandList(sender, subcommands);
}
}
return true;
} else {
return false;
}
}
@Override
public @NotNull List<String> tabComplete(@NotNull CommandSender sender, @NotNull String alias, String[] args) throws IllegalArgumentException {
if (sender.hasPermission("redmc.manage")) {
if (args.length == 1) { //prank <subcommand> <args>
ArrayList<String> subcommandsArguments = new ArrayList<>();
for (int i = 0; i < getSubCommands().size(); i++) {
subcommandsArguments.add(getSubCommands().get(i).getName());
}
return subcommandsArguments;
} else if (args.length >= 2) {
for (int i = 0; i < getSubCommands().size(); i++) {
if (args[0].equalsIgnoreCase(getSubCommands().get(i).getName())) {
List<String> subCommandArgs = getSubCommands().get(i).getSubcommandArguments(
(Player) sender, args
);
if (subCommandArgs != null)
return subCommandArgs;
return Collections.emptyList();
}
}
}
return Collections.emptyList();
} else {
return List.of();
}
}
}

View File

@@ -0,0 +1,47 @@
package me.monster.commands;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.List;
/**
* A subcommand like: /corecommand subcommand args
* A further example: /chunkcollector buy - buy would be the subcommand that opens a buy menu in that plugin
*/
public abstract class SubCommand {
/**
* @return The name of the subcommand
*/
public abstract String getName();
/**
* @return The aliases that can be used for this command. Can be null
*/
public abstract List<String> getAliases();
/**
* @return A description of what the subcommand does to be displayed
*/
public abstract String getDescription();
/**
* @return An example of how to use the subcommand
*/
public abstract String getSyntax();
/**
* @param sender The thing that ran the command
* @param args The args passed into the command when run
*/
public abstract void perform(CommandSender sender, String[] args);
/**
* @param player The player who ran the command
* @param args The args passed into the command when run
* @return A list of arguments to be suggested for autocomplete
*/
public abstract List<String> getSubcommandArguments(Player player, String[] args);
}

View File

@@ -0,0 +1,21 @@
package me.monster.exceptions;
public class MenuManagerException extends Exception {
public MenuManagerException() {
super();
}
public MenuManagerException(String message) {
super(message);
}
public MenuManagerException(String message, Throwable cause) {
super(message, cause);
}
public MenuManagerException(Throwable cause) {
super(cause);
}
}

View File

@@ -0,0 +1,4 @@
package me.monster.exceptions;
public class MenuManagerNotSetupException extends Exception {
}

View File

@@ -0,0 +1,276 @@
package me.monster.heads;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.Skull;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Base64;
import java.util.Objects;
import java.util.UUID;
/**
* A library for the Bukkit API to create player skulls
* from names, base64 strings, and texture URLs.
* <p>
* Does not use any NMS code, and should work across all versions.
*
* @author Dean B on 12/28/2016.
*/
public class SkullCreator {
/**
* Creates a player skull based on a player's name.
*
* @param name The Player's name
* @return The head of the Player
* @deprecated names don't make for good identifiers
*/
@Deprecated
public static ItemStack itemFromName(String name) {
ItemStack item = getPlayerSkullItem();
return itemWithName(item, name);
}
/**
* Creates a player skull based on a player's name.
*
* @param item The item to apply the name to
* @param name The Player's name
* @return The head of the Player
* @deprecated names don't make for good identifiers
*/
@Deprecated
public static ItemStack itemWithName(ItemStack item, String name) {
notNull(item, "item");
notNull(name, "name");
return Bukkit.getUnsafe().modifyItemStack(item,
"{SkullOwner:\"" + name + "\"}"
);
}
/**
* Creates a player skull with a UUID. 1.13 only.
*
* @param id The Player's UUID
* @return The head of the Player
*/
public static ItemStack itemFromUuid(UUID id) {
ItemStack item = getPlayerSkullItem();
return itemWithUuid(item, id);
}
/**
* Creates a player skull based on a UUID. 1.13 only.
*
* @param item The item to apply the name to
* @param id The Player's UUID
* @return The head of the Player
*/
public static ItemStack itemWithUuid(ItemStack item, UUID id) {
notNull(item, "item");
notNull(id, "id");
SkullMeta meta = (SkullMeta) item.getItemMeta();
Objects.requireNonNull(meta).setOwningPlayer(Bukkit.getOfflinePlayer(id));
item.setItemMeta(meta);
return item;
}
/**
* Creates a player skull based on a Mojang server URL.
*
* @param url The URL of the Mojang skin
* @return The head associated with the URL
*/
public static ItemStack itemFromUrl(String url) {
ItemStack item = getPlayerSkullItem();
return itemWithUrl(item, url);
}
/**
* Creates a player skull based on a Mojang server URL.
*
* @param item The item to apply the skin to
* @param url The URL of the Mojang skin
* @return The head associated with the URL
*/
public static ItemStack itemWithUrl(ItemStack item, String url) {
notNull(item, "item");
notNull(url, "url");
return itemWithBase64(item, urlToBase64(url));
}
/**
* Creates a player skull based on a base64 string containing the link to the skin.
*
* @param base64 The base64 string containing the texture
* @return The head with a custom texture
*/
public static ItemStack itemFromBase64(String base64) {
ItemStack item = getPlayerSkullItem();
return itemWithBase64(item, base64);
}
/**
* Applies the base64 string to the ItemStack.
*
* @param item The ItemStack to put the base64 onto
* @param base64 The base64 string containing the texture
* @return The head with a custom texture
*/
public static ItemStack itemWithBase64(ItemStack item, String base64) {
notNull(item, "item");
notNull(base64, "base64");
UUID hashAsId = new UUID(base64.hashCode(), base64.hashCode());
return Bukkit.getUnsafe().modifyItemStack(item,
"{SkullOwner:{Id:\"" + hashAsId + "\",Properties:{textures:[{Value:\"" + base64 + "\"}]}}}"
);
}
/**
* Sets the block to a skull with the given name.
*
* @param block The block to set
* @param name The player to set it to
* @deprecated names don't make for good identifiers
*/
@Deprecated
public static void blockWithName(Block block, String name) {
notNull(block, "block");
notNull(name, "name");
setBlockType(block);
((Skull) block.getState()).setOwningPlayer(Bukkit.getOfflinePlayer(name));
}
/**
* Sets the block to a skull with the given UUID.
*
* @param block The block to set
* @param id The player to set it to
*/
public static void blockWithUuid(Block block, UUID id) {
notNull(block, "block");
notNull(id, "id");
setBlockType(block);
((Skull) block.getState()).setOwningPlayer(Bukkit.getOfflinePlayer(id));
}
/**
* Sets the block to a skull with the given UUID.
*
* @param block The block to set
* @param url The mojang URL to set it to use
*/
public static void blockWithUrl(Block block, String url) {
notNull(block, "block");
notNull(url, "url");
blockWithBase64(block, urlToBase64(url));
}
/**
* Sets the block to a skull with the given UUID.
*
* @param block The block to set
* @param base64 The base64 to set it to use
*/
public static void blockWithBase64(Block block, String base64) {
notNull(block, "block");
notNull(base64, "base64");
UUID hashAsId = new UUID(base64.hashCode(), base64.hashCode());
String args = String.format(
"%d %d %d %s",
block.getX(),
block.getY(),
block.getZ(),
"{Owner:{Id:\"" + hashAsId + "\",Properties:{textures:[{Value:\"" + base64 + "\"}]}}}"
);
if (newerApi()) {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "data merge block " + args);
} else {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "blockdata " + args);
}
}
private static boolean newerApi() {
try {
Material.valueOf("PLAYER_HEAD");
return true;
} catch (IllegalArgumentException e) { // If PLAYER_HEAD doesn't exist
return false;
}
}
private static ItemStack getPlayerSkullItem() {
if (newerApi()) {
return new ItemStack(Material.valueOf("PLAYER_HEAD"));
} else {
return new ItemStack(Material.valueOf("SKULL_ITEM"), 1, (byte) 3);
}
}
private static void setBlockType(Block block) {
try {
block.setType(Material.valueOf("PLAYER_HEAD"), false);
} catch (IllegalArgumentException e) {
block.setType(Material.valueOf("SKULL"), false);
}
}
private static void notNull(Object o, String name) {
if (o == null) {
throw new NullPointerException(name + " should not be null!");
}
}
private static String urlToBase64(String url) {
URI actualUrl;
try {
actualUrl = new URI(url);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String toEncode = "{\"textures\":{\"SKIN\":{\"url\":\"" + actualUrl + "\"}}}";
return Base64.getEncoder().encodeToString(toEncode.getBytes());
}
}
/* Format for skull
{
display:{
Name:"Cheese"
},
SkullOwner:{
Id:"9c919b83-f3fe-456f-a824-7d1d08cc8bd2",
Properties:{
textures:[
{
Value:"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvOTU1ZDYxMWE4NzhlODIxMjMxNzQ5YjI5NjU3MDhjYWQ5NDI2NTA2NzJkYjA5ZTI2ODQ3YTg4ZTJmYWMyOTQ2In19fQ=="
}
]
}
}
}
*/

View File

@@ -0,0 +1,36 @@
package me.monster.items;
import me.monster.colors.ColorUtils;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* Class for Utilities related to Minecraft/Bukkit Items
*/
public class ItemUtils {
/**
* @param material The material to base the ItemStack on
* @param displayName The display name of the ItemStack
* @param lore The lore of the ItemStack, with the Strings being automatically color coded with ColorTranslator
* @return The constructed ItemStack object
*/
public static ItemStack makeItem(Material material, String displayName, String... lore) {
ItemStack item = new ItemStack(material);
ItemMeta itemMeta = item.getItemMeta();
assert itemMeta != null;
itemMeta.setDisplayName(displayName);
//Automatically translate color codes provided
itemMeta.setLore(Arrays.stream(lore).map(ColorUtils::translateColorCodes).collect(Collectors.toList()));
item.setItemMeta(itemMeta);
return item;
}
}

View File

@@ -0,0 +1,141 @@
package me.monster.menu;
import me.monster.colors.ColorUtils;
import me.monster.exceptions.MenuManagerException;
import me.monster.exceptions.MenuManagerNotSetupException;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.stream.Collectors;
/*
Defines the behavior and attributes of all menus in our plugin
*/
public abstract class Menu implements InventoryHolder {
//Protected values that can be accessed in the menus
protected PlayerMenuUtility playerMenuUtility;
protected Player player;
protected Inventory inventory;
protected ItemStack FILLER_GLASS = makeItem(Material.GRAY_STAINED_GLASS_PANE, " ");
//Constructor for Menu. Pass in a PlayerMenuUtility so that
// we have information on whose menu this is and
// what info is to be transferred
public Menu(PlayerMenuUtility playerMenuUtility) {
this.playerMenuUtility = playerMenuUtility;
this.player = playerMenuUtility.getOwner();
}
//let each menu decide their name
public abstract String getMenuName();
//let each menu decide their slot amount
public abstract int getSlots();
public abstract boolean cancelAllClicks();
//let each menu decide how the items in the menu will be handled when clicked
public abstract void handleMenu(InventoryClickEvent e) throws MenuManagerNotSetupException, MenuManagerException;
//let each menu decide what items are to be placed in the inventory menu
public abstract void setMenuItems();
//When called, an inventory is created and opened for the player
public void open() {
//The owner of the inventory created is the Menu itself,
// so we are able to reverse engineer the Menu object from the
// inventoryHolder in the MenuListener class when handling clicks
inventory = Bukkit.createInventory(this, getSlots(), getMenuName());
//grab all the items specified to be used for this menu and add to inventory
this.setMenuItems();
//open the inventory for the player
playerMenuUtility.getOwner().openInventory(inventory);
playerMenuUtility.pushMenu(this);
}
public void back() throws MenuManagerException, MenuManagerNotSetupException {
MenuManager.openMenu(playerMenuUtility.lastMenu().getClass(), playerMenuUtility.getOwner());
}
protected void reloadItems() {
for (int i = 0; i < inventory.getSize(); i++) {
inventory.setItem(i, null);
}
setMenuItems();
}
protected void reload() throws MenuManagerException, MenuManagerNotSetupException {
player.closeInventory();
MenuManager.openMenu(this.getClass(), player);
}
//Overridden method from the InventoryHolder interface
@Override
public @NotNull Inventory getInventory() {
return inventory;
}
/**
* This will fill all the empty slots with "filler glass"
*/
//Helpful utility method to fill all remaining slots with "filler glass"
public void setFillerGlass() {
for (int i = 0; i < getSlots(); i++) {
if (inventory.getItem(i) == null) {
inventory.setItem(i, FILLER_GLASS);
}
}
}
/**
* @param itemStack Placed into every empty slot when ran
*/
public void setFillerGlass(ItemStack itemStack) {
for (int i = 0; i < getSlots(); i++) {
if (inventory.getItem(i) == null) {
inventory.setItem(i, itemStack);
}
}
}
/**
* @param material The material to base the ItemStack on
* @param displayName The display name of the ItemStack
* @param lore The lore of the ItemStack, with the Strings being automatically color coded with ColorTranslator
* @return The constructed ItemStack object
*/
public ItemStack makeItem(Material material, String displayName, String... lore) {
ItemStack item = new ItemStack(material);
ItemMeta itemMeta = item.getItemMeta();
assert itemMeta != null;
itemMeta.setDisplayName(displayName);
//Automatically translate color codes provided
itemMeta.setLore(Arrays.stream(lore).map(ColorUtils::translateColorCodes).collect(Collectors.toList()));
item.setItemMeta(itemMeta);
return item;
}
/**
* Called when a player closes this menu
* Override this method to handle menu closing events
*/
public void handleMenuClose() {
// Default empty implementation
// Subclasses can override this if they want to handle menu closing
}
}

View File

@@ -0,0 +1,53 @@
package me.monster.menu;
import me.monster.exceptions.MenuManagerException;
import me.monster.exceptions.MenuManagerNotSetupException;
import net.md_5.bungee.api.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.InventoryHolder;
public class MenuListener implements Listener {
@EventHandler
public void onMenuClick(InventoryClickEvent e) {
InventoryHolder holder = e.getInventory().getHolder();
//If the inventoryholder of the inventory clicked on
// is an instance of Menu, then gg. The reason that
// an InventoryHolder can be a Menu is because our Menu
// class implements InventoryHolder!!
if (holder instanceof Menu menu) {
if (e.getCurrentItem() == null) { //deal with null exceptions
return;
}
if (menu.cancelAllClicks()) {
e.setCancelled(true); //prevent them from fucking with the inventory
}
//Call the handleMenu object which takes the event and processes it
try {
menu.handleMenu(e);
} catch (MenuManagerNotSetupException menuManagerNotSetupException) {
System.out.println(ChatColor.RED + "THE MENU MANAGER HAS NOT BEEN CONFIGURED. CALL MENUMANAGER.SETUP()");
} catch (MenuManagerException menuManagerException) {
menuManagerException.printStackTrace();
}
}
}
@EventHandler
public void onMenuClose(InventoryCloseEvent e) {
InventoryHolder holder = e.getInventory().getHolder();
if (holder instanceof Menu menu) {
menu.handleMenuClose();
}
}
}

View File

@@ -0,0 +1,85 @@
package me.monster.menu;
import me.monster.exceptions.MenuManagerException;
import me.monster.exceptions.MenuManagerNotSetupException;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
/**
* Used to interface with the Menu Manager API
*/
public class MenuManager {
//each player will be assigned their own PlayerMenuUtility object
private static final HashMap<Player, PlayerMenuUtility> playerMenuUtilityMap = new HashMap<>();
private static boolean isSetup = false;
private static void registerMenuListener(Server server, Plugin plugin) {
boolean isAlreadyRegistered = false;
for (RegisteredListener rl : InventoryClickEvent.getHandlerList().getRegisteredListeners()) {
if (rl.getListener() instanceof MenuListener) {
isAlreadyRegistered = true;
break;
}
}
if (!isAlreadyRegistered) {
server.getPluginManager().registerEvents(new MenuListener(), plugin);
}
}
/**
* @param server The instance of your server. Provide by calling getServer()
* @param plugin The instance of the plugin using this API. Can provide in plugin class by passing this keyword
*/
public static void setup(Server server, Plugin plugin) {
plugin.getLogger().info("Menu Manager has been setup");
registerMenuListener(server, plugin);
isSetup = true;
}
/**
* @param menuClass The class reference of the Menu you want to open for a player
* @param player The player to open the menu for
* @throws MenuManagerNotSetupException Thrown if the setup() method has not been called and used properly
*/
public static void openMenu(Class<? extends Menu> menuClass, Player player) throws MenuManagerException, MenuManagerNotSetupException {
try {
menuClass.getConstructor(PlayerMenuUtility.class).newInstance(getPlayerMenuUtility(player)).open();
} catch (InstantiationException e) {
throw new MenuManagerException("Failed to instantiate menu class", e);
} catch (IllegalAccessException e) {
throw new MenuManagerException("Illegal access while trying to instantiate menu class", e);
} catch (InvocationTargetException e) {
throw new MenuManagerException("An error occurred while trying to invoke the menu class constructor", e);
} catch (NoSuchMethodException e) {
throw new MenuManagerException("The menu class constructor could not be found", e);
}
}
public static PlayerMenuUtility getPlayerMenuUtility(Player p) throws MenuManagerNotSetupException {
if (!isSetup) {
throw new MenuManagerNotSetupException();
}
PlayerMenuUtility playerMenuUtility;
if (!(playerMenuUtilityMap.containsKey(p))) { //See if the player has a pmu "saved" for them
//Construct PMU
playerMenuUtility = new PlayerMenuUtility(p);
playerMenuUtilityMap.put(p, playerMenuUtility);
return playerMenuUtility;
} else {
return playerMenuUtilityMap.get(p); //Return the object by using the provided player
}
}
}

View File

@@ -0,0 +1,201 @@
package me.monster.menu;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.List;
public abstract class PaginatedMenu extends Menu {
//The items being paginated
protected List<Object> data;
//Keep track of what page the menu is on
protected int page = 0;
//28 is max items because with the border set below,
//28 empty slots are remaining.
protected int maxItemsPerPage = 28;
// Add cache field
private List<ItemStack> cachedItems;
public PaginatedMenu(PlayerMenuUtility playerMenuUtility) {
super(playerMenuUtility);
}
/**
* @return A list of ItemStacks that you want to be placed in the menu. This is the data that will be paginated
* You can also use this as a way to convert your data to items if you need to
*/
public abstract List<ItemStack> dataToItems();
/**
* @return A hashmap of items you want to be placed in the paginated menu border. This will override any items already placed by default. Key = slot, Value = Item
*/
@Nullable
public abstract HashMap<Integer, ItemStack> getCustomMenuBorderItems();
/**
* Set the border and menu buttons for the menu. Override this method to provide a custom menu border or specify custom items in customMenuBorderItems()
*/
protected void addMenuBorder() {
// First page button
inventory.setItem(47, makeItem(Material.DARK_OAK_BUTTON, ChatColor.GREEN + "First Page"));
// Previous page button
inventory.setItem(48, makeItem(Material.DARK_OAK_BUTTON, ChatColor.GREEN + "Previous"));
// Close button
inventory.setItem(49, makeItem(Material.BARRIER, ChatColor.DARK_RED + "Close"));
// Next page button
inventory.setItem(50, makeItem(Material.DARK_OAK_BUTTON, ChatColor.GREEN + "Next"));
// Last page button
inventory.setItem(51, makeItem(Material.DARK_OAK_BUTTON, ChatColor.GREEN + "Last Page"));
for (int i = 0; i < 10; i++) {
if (inventory.getItem(i) == null) {
inventory.setItem(i, super.FILLER_GLASS);
}
}
inventory.setItem(17, super.FILLER_GLASS);
inventory.setItem(18, super.FILLER_GLASS);
inventory.setItem(26, super.FILLER_GLASS);
inventory.setItem(27, super.FILLER_GLASS);
inventory.setItem(35, super.FILLER_GLASS);
inventory.setItem(36, super.FILLER_GLASS);
for (int i = 44; i < 54; i++) {
if (inventory.getItem(i) == null) {
inventory.setItem(i, super.FILLER_GLASS);
}
}
//place the custom items if they exist
if (getCustomMenuBorderItems() != null) {
getCustomMenuBorderItems().forEach((integer, itemStack) -> inventory.setItem(integer, itemStack));
}
}
/**
* Gets the paginated items, using cache if available
* @return List of ItemStacks to display
*/
protected List<ItemStack> getItems() {
if (cachedItems == null) {
cachedItems = dataToItems();
}
return cachedItems;
}
/**
* Clears the item cache, forcing items to be reloaded next time
*/
protected void invalidateCache() {
cachedItems = null;
}
/**
* Place each item in the paginated menu, automatically coded by default but override if you want custom functionality. Calls the loopCode() method you define for each item returned in the getData() method
*/
@Override
public void setMenuItems() {
addMenuBorder();
List<ItemStack> items = getItems(); // Use cached items
int slot = 10;
for (int i = 0; i < maxItemsPerPage; i++) {
int index = maxItemsPerPage * page + i;
if (index >= items.size()) break;
if (slot % 9 == 8) slot += 2;
inventory.setItem(slot, items.get(index));
slot++;
}
}
/**
* @return true if successful, false if already on the first page
*/
public boolean prevPage() {
if (page == 0) {
return false;
} else {
page = page - 1;
reloadItems();
return true;
}
}
/**
* @return true if successful, false if already on the last page
*/
public boolean nextPage() {
int totalItems = getItems().size(); // Use cached items
int lastPageNumber = (totalItems - 1) / maxItemsPerPage;
if (page < lastPageNumber) {
page++;
reloadItems();
return true;
}
return false;
}
public int getMaxItemsPerPage() {
return maxItemsPerPage;
}
public int getCurrentPage() {
return page + 1; // Convert to 1-based page numbers for display
}
public int getTotalPages() {
return ((getItems().size() - 1) / maxItemsPerPage) + 1; // Use cached items
}
@Override
public void open() {
invalidateCache(); // Force items to be reloaded when menu opens
super.open();
}
/**
* Refreshes the menu data and reloads items while keeping the menu open
*/
public void refreshData() {
invalidateCache();
reloadItems();
}
/**
* Sets the current page to the first page (0)
* @return true if the page was changed, false if already on first page
*/
public boolean firstPage() {
if (page == 0) {
return false;
}
page = 0;
reloadItems();
return true;
}
/**
* Sets the current page to the last page
* @return true if the page was changed, false if already on last page
*/
public boolean lastPage() {
int lastPageNum = (getItems().size() - 1) / maxItemsPerPage;
if (page == lastPageNum) {
return false;
}
page = lastPageNum;
reloadItems();
return true;
}
}

View File

@@ -0,0 +1,87 @@
package me.monster.menu;
/*
Companion class to all menus. This is needed to pass information across the entire
menu system no matter how many inventories are opened or closed.
Each player has one of these objects, and only one.
*/
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.Stack;
public class PlayerMenuUtility {
private final Player owner;
private final HashMap<String, Object> dataMap = new HashMap<>();
private final Stack<Menu> history = new Stack<>();
public PlayerMenuUtility(Player p) {
this.owner = p;
}
public Player getOwner() {
return owner;
}
/**
* @param identifier A key to store the data by
* @param data The data itself to be stored
*/
public void setData(String identifier, Object data) {
this.dataMap.put(identifier, data);
}
public void setData(Enum identifier, Object data) {
this.dataMap.put(identifier.toString(), data);
}
/**
* @param identifier The key for the data stored in the PMC
* @return The retrieved value or null if not found
*/
public Object getData(String identifier) {
return this.dataMap.get(identifier);
}
public Object getData(Enum identifier) {
return this.dataMap.get(identifier.toString());
}
public <T> T getData(String identifier, Class<T> classRef) {
Object obj = this.dataMap.get(identifier);
if (obj == null) {
return null;
} else {
return classRef.cast(obj);
}
}
public <T> T getData(Enum identifier, Class<T> classRef) {
Object obj = this.dataMap.get(identifier.toString());
if (obj == null) {
return null;
} else {
return classRef.cast(obj);
}
}
/**
* @return Get the previous menu that was opened for the player
*/
public Menu lastMenu() {
this.history.pop(); //Makes back() work for some reason
return this.history.pop();
}
public void pushMenu(Menu menu) {
this.history.push(menu);
}
}

View File

@@ -0,0 +1,8 @@
name: RedMC-API
version: '1.0'
main: me.monster.Main
api-version: '1.21'
prefix: RedMC
load: STARTUP
authors: [ 41ms_ ]
website: 41ms.fr