diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore index cb309b4..72c40c0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,9 @@ replay_pid* .bloop/ project/metals.sbt + +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..241dda3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,162 @@ +import java.text.SimpleDateFormat +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import java.util.* + +val kotlinVersion = "2.2.0" + +plugins { + id("com.gradleup.shadow") version "8.3.6" + kotlin("jvm") version "2.2.0" +} + +group = "org.winlogon.powertools" + +fun getTime(): String { + val sdf = SimpleDateFormat("yyMMdd-HHmm") + sdf.timeZone = TimeZone.getTimeZone("UTC") + return sdf.format(Date()).toString() +} + +val shortVersion: String? = if (project.hasProperty("ver")) { + val ver = project.property("ver").toString() + if (ver.startsWith("v")) { + ver.substring(1).uppercase() + } else { + ver.uppercase() + } +} else { + null +} + +val version: String = when { + shortVersion.isNullOrEmpty() -> "${getTime()}-SNAPSHOT" + shortVersion.contains("-RC-") -> shortVersion.substringBefore("-RC-") + "-SNAPSHOT" + else -> shortVersion +} + +val pluginName = rootProject.name +val pluginVersion = version +val pluginPackage = project.group.toString() +val projectName = rootProject.name + +repositories { + maven { + name = "papermc" + url = uri("https://repo.papermc.io/repository/maven-public/") + content { + includeModule("io.papermc.paper", "paper-api") + includeModule("net.md-5", "bungeecord-chat") + } + } + maven { + name = "minecraft" + url = uri("https://libraries.minecraft.net") + content { + includeModule("com.mojang", "brigadier") + } + } + maven { + name = "winlogon" + url = uri("https://maven.winlogon.org/releases/") + } + maven { + url = uri("https://repo.codemc.org/repository/maven-public/") + } + mavenCentral() +} + +val lampVersion = "4.0.0-rc.12" + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") + compileOnly("org.winlogon:retrohue:0.1.1") + compileOnly("org.winlogon:asynccraftr:0.1.0") + compileOnly("de.tr7zw:item-nbt-api:2.15.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") + + implementation("io.github.revxrsal:lamp.common:$lampVersion") + implementation("io.github.revxrsal:lamp.bukkit:$lampVersion") + implementation("io.github.revxrsal:lamp.brigadier:$lampVersion") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") + testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.processResources { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + filesMatching("**/paper-plugin.yml") { + expand( + "NAME" to pluginName, + "VERSION" to pluginVersion, + "PACKAGE" to pluginPackage + ) + } +} + +tasks.register("createMojangMapped") { + from(layout.projectDirectory.file("empty-marker")) + into(layout.buildDirectory.dir("generated-resources/META-INF")) + rename { ".mojang-mapped" } +} + +tasks.processResources { + dependsOn("createMojangMapped") + from(layout.buildDirectory.dir("generated-resources/META-INF")) { + into("META-INF") + } +} + +tasks.shadowJar { + archiveClassifier.set("") + minimize() + dependencies { + include(dependency("org.jetbrains.kotlin:kotlin-stdlib")) + include(dependency("io.github.revxrsal:lamp.common:$lampVersion")) + include(dependency("io.github.revxrsal:lamp.bukkit:$lampVersion")) + include(dependency("io.github.revxrsal:lamp.brigadier:$lampVersion")) + } + relocate("io.github.revxrsal.lamp", "${project.group}.shaded.lamp") + relocate("kotlin", "${project.group}.shaded.kotlin") + mergeServiceFiles() +} + +tasks.jar { + enabled = false +} + +tasks.assemble { + dependsOn(tasks.shadowJar) +} + +tasks.withType { + options.compilerArgs.add("-parameters") +} + +tasks.withType { + compilerOptions { + javaParameters = true + } +} + +tasks.register("printProjectName") { + doLast { + println(projectName) + } +} + +var shadowJarTask = tasks.shadowJar.get() +tasks.register("release") { + dependsOn(tasks.build) + doLast { + if (!version.endsWith("-SNAPSHOT")) { + shadowJarTask.archiveFile.get().asFile.renameTo( + file("${layout.buildDirectory.get()}/libs/${rootProject.name}.jar") + ) + } + } +} diff --git a/build.sbt b/build.sbt deleted file mode 100644 index 334986e..0000000 --- a/build.sbt +++ /dev/null @@ -1,36 +0,0 @@ -import Dependencies._ - -lazy val mainScalaClass = "org.winlogon.powertools.PowerToolsPlugin" -lazy val scalaVer = "3.3.6" - -ThisBuild / scalaVersion := scalaVer -ThisBuild / version := "0.4.0-SNAPSHOT" -ThisBuild / organization := "org.winlogon" -ThisBuild / organizationName := "winlogon" -Compile / mainClass := Some(mainScalaClass) - -lazy val root = (project in file(".")) - .settings( - name := "powertools", - assembly / assemblyOption := (assembly / assemblyOption).value.withIncludeScala(false) - ) - -// Merge strategy for avoiding conflicts in dependencies -assembly / assemblyMergeStrategy := { - case PathList("META-INF", xs @ _*) => MergeStrategy.discard - case _ => MergeStrategy.first -} - -assembly / mainClass := Some(mainScalaClass) - -libraryDependencies ++= Seq( - "io.papermc.paper" % "paper-api" % "1.21.6-R0.1-SNAPSHOT" % Provided, - "dev.jorel" % "commandapi-bukkit-core" % "10.0.1" % Provided, - "org.winlogon" % "retrohue" % "0.1.1" % Provided -) - -resolvers ++= Seq( - "papermc-repo" at "https://repo.papermc.io/repository/maven-public/", - "codemc" at "https://repo.codemc.org/repository/maven-public/", - "winlogon-code" at "https://maven.winlogon.org/releases", -) diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..009b8e0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4ac3234 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/project/Dependencies.scala b/project/Dependencies.scala deleted file mode 100644 index fc623e8..0000000 --- a/project/Dependencies.scala +++ /dev/null @@ -1,5 +0,0 @@ -import sbt._ - -object Dependencies { - lazy val munit = "org.scalameta" %% "munit" % "0.7.29" -} diff --git a/project/build.properties b/project/build.properties deleted file mode 100644 index 73df629..0000000 --- a/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=1.10.7 diff --git a/project/plugins.sbt b/project/plugins.sbt deleted file mode 100644 index c46ce74..0000000 --- a/project/plugins.sbt +++ /dev/null @@ -1 +0,0 @@ -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0") diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..33a04b9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "PowerTools" diff --git a/src/main/kotlin/org/winlogon/powertools/AbsorbAnimal.kt b/src/main/kotlin/org/winlogon/powertools/AbsorbAnimal.kt new file mode 100644 index 0000000..ed14356 --- /dev/null +++ b/src/main/kotlin/org/winlogon/powertools/AbsorbAnimal.kt @@ -0,0 +1,150 @@ +package org.winlogon.powertools + +import de.tr7zw.changeme.nbtapi.NBT +import de.tr7zw.changeme.nbtapi.NBTEntity +import de.tr7zw.changeme.nbtapi.NBTItem +import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT +import de.tr7zw.changeme.nbtapi.iface.ReadableNBT +import de.tr7zw.changeme.nbtapi.iface.ReadWriteItemNBT + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import org.bukkit.entity.Tameable +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.ItemMeta +import org.bukkit.inventory.meta.SpawnEggMeta +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.persistence.PersistentDataType +import org.bukkit.event.player.PlayerInteractAtEntityEvent +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.HandlerList +import org.bukkit.plugin.Plugin + +import java.util.function.Function +import java.util.UUID + +class AbsorbAnimal(private val plugin: Plugin) { + private companion object { + private val plainSerializer = PlainTextComponentSerializer.plainText() + private val EGG_MAP: Map = Material.values() + .asSequence() + .filter { it.name.endsWith("_SPAWN_EGG") } + .mapNotNull { mat -> + // remove the suffix to get the entity name + val entityName = mat.name.removeSuffix("_SPAWN_EGG") + + // try to match it to EntityType + runCatching { EntityType.valueOf(entityName) } + .getOrNull() + ?.let { entityType -> entityType to mat } + } + .toMap() + } + + fun absorbPetOf(player: Player) { + // 5 block range + val targetEntity = player.getTargetEntity(5) ?: run { + ChatFormatting.sendError(player, "You must be looking at a pet within 5 blocks.") + return + } + + if (targetEntity is Tameable && targetEntity.isTamed && + targetEntity.owner?.uniqueId == player.uniqueId) { + absorbPet(player, targetEntity) + } else { + ChatFormatting.sendError(player, "You must be looking at a tamed pet that you own.") + } + } + + private fun absorbPet(player: Player, pet: Tameable) { + val nbtString = NBT.get(pet, Function { nbt -> + nbt.toString() + }) ?: run { + ChatFormatting.sendError(player, "failed to read pet data") + return + } + + Bukkit.getLogger().info("${player.name}'s pet NBT data is: $nbtString") + + val spawnEggMaterial = getEggMaterialFor(pet) ?: run { + ChatFormatting.sendError(player, "invalid pet type") + return + } + + val baseEgg = ItemStack(spawnEggMaterial) + val compound = NBT.parseNBT(nbtString) + + // remove unnecessary keys + listOf("UUID", "Pos", "Dimension", "id").forEach(compound::removeKey) + + val ownerIntArray = compound.getIntArray("Owner") + if (ownerIntArray != null && ownerIntArray.size == 4) { + // convert int array to UUID + val msb = ownerIntArray[0].toLong() shl 32 or (ownerIntArray[1].toLong() and 0xFFFFFFFFL) + val lsb = ownerIntArray[2].toLong() shl 32 or (ownerIntArray[3].toLong() and 0xFFFFFFFFL) + val uuid = UUID(msb, lsb) + compound.removeKey("Owner") + compound.setString("OwnerUUID", uuid.toString()) + } + + val petName = pet.customName() + if (petName != null) { + val customName = plainSerializer.serialize(petName) + val jsonName = "{\"text\":\"${customName}\"}" + compound.setString("CustomName", jsonName) + } + + val finalEggStack: ItemStack = NBT.modify(baseEgg, Function { itemNbt -> + val entityTag = itemNbt.getOrCreateCompound("EntityTag") + entityTag.mergeCompound(compound) + baseEgg + }) + + val meta = finalEggStack.itemMeta ?: return + val entityType = pet.type + meta.displayName(fmt("Absorbed ${entityType.name.lowercase().replace('_',' ')}")) + meta.lore(listOf( + fmt("Right click to spawn"), + fmt("Pet name: ${plainSerializer.serialize(pet.name())}"), + fmt("Pet level: ${pet.health}"), + )) + meta.persistentDataContainer.apply { + set(NamespacedKey(plugin, "absorbed_entity_type"), PersistentDataType.STRING, entityType.name) + set(NamespacedKey(plugin, "absorbed_nbt"), PersistentDataType.STRING, compound.toString()) + } + finalEggStack.itemMeta = meta + + pet.remove() + player.inventory.addItem(finalEggStack) + player.sendMessage(fmt("&7Successfully absorbed your pet!")) + } + + private fun fmt(s: String): Component { + return ChatFormatting.colorConverter.convertToComponent(s, '&') + } + + private fun getEggMaterialFor(pet: Tameable): Material? { + return EGG_MAP[pet.type] + } +} + +class ClickListener(private val plugin: Plugin, private val player: Player) : Listener { + @EventHandler + fun onClick(event: PlayerInteractAtEntityEvent) { + if (event.player == player && event.hand == EquipmentSlot.HAND) { + player.rayTraceEntities(5)?.hitEntity.let { + if (it == event.rightClicked) { + AbsorbAnimal(plugin).absorbPetOf(player) + HandlerList.unregisterAll(this@ClickListener) + } + } + } + } + } diff --git a/src/main/kotlin/org/winlogon/powertools/ChatFormatting.kt b/src/main/kotlin/org/winlogon/powertools/ChatFormatting.kt new file mode 100644 index 0000000..12ca938 --- /dev/null +++ b/src/main/kotlin/org/winlogon/powertools/ChatFormatting.kt @@ -0,0 +1,27 @@ +package org.winlogon.powertools + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import org.winlogon.retrohue.RetroHue +import org.bukkit.command.CommandSender + +object ChatFormatting { + private val miniMessage = MiniMessage.miniMessage() + val colorConverter = RetroHue(miniMessage) + + fun sendError(target: CommandSender, err: String) { + val formattedMessage = colorConverter.convertToComponent( + "<#F93822>Error&7: ${sentenceCase(err)}", '&' + ) + target.sendMessage(formattedMessage) + } + + private fun sentenceCase(input: String): String { + return input.trim().let { + if (it.isEmpty()) it + else it[0].uppercaseChar() + it.substring(1) + }.let { + if (it.endsWith(".")) it else "$it." + } + } +} diff --git a/src/main/kotlin/org/winlogon/powertools/PowerToolsLoader.kt b/src/main/kotlin/org/winlogon/powertools/PowerToolsLoader.kt new file mode 100644 index 0000000..286e67c --- /dev/null +++ b/src/main/kotlin/org/winlogon/powertools/PowerToolsLoader.kt @@ -0,0 +1,44 @@ +package org.winlogon.powertools + +import io.papermc.paper.plugin.loader.PluginClasspathBuilder +import io.papermc.paper.plugin.loader.PluginLoader +import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver + +import org.eclipse.aether.artifact.DefaultArtifact +import org.eclipse.aether.graph.Dependency +import org.eclipse.aether.repository.RemoteRepository + +class PowerToolsLoader : PluginLoader { + override fun classloader(classpathBuilder: PluginClasspathBuilder) { + val resolver = MavenLibraryResolver() + + val resolvers = mapOf( + "central" to MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR, + "winlogon-code" to "https://maven.winlogon.org/releases", + "codemc" to "https://repo.codemc.io/repository/maven-public/", + ) + + resolvers.forEach { (name, url) -> + resolver.addRepository( + RemoteRepository.Builder(name, "default", url).build() + ) + } + + val dependencies = mapOf( + "org.winlogon:retrohue" to "0.1.1", + "org.winlogon:asynccraftr" to "0.1.0", + "de.tr7zw:item-nbt-api" to "2.15.1", + ) + + dependencies.forEach { (artifactId, version) -> + resolver.addDependency( + Dependency( + DefaultArtifact("$artifactId:$version"), + null + ) + ) + } + + classpathBuilder.addLibrary(resolver) + } +} diff --git a/src/main/kotlin/org/winlogon/powertools/PowerToolsPlugin.kt b/src/main/kotlin/org/winlogon/powertools/PowerToolsPlugin.kt new file mode 100644 index 0000000..80f2cd7 --- /dev/null +++ b/src/main/kotlin/org/winlogon/powertools/PowerToolsPlugin.kt @@ -0,0 +1,398 @@ +package org.winlogon.powertools + +import revxrsal.commands.Lamp +import revxrsal.commands.annotation.Command +import revxrsal.commands.annotation.Default +import revxrsal.commands.annotation.Dependency +import revxrsal.commands.annotation.Named +import revxrsal.commands.annotation.Optional +import revxrsal.commands.annotation.Subcommand +import revxrsal.commands.annotation.SuggestWith + +import revxrsal.commands.bukkit.BukkitLamp +import revxrsal.commands.bukkit.actor.BukkitCommandActor +import revxrsal.commands.bukkit.annotation.CommandPermission + +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + +import org.bukkit.Bukkit +import org.bukkit.GameMode +import org.bukkit.Material +import org.bukkit.attribute.Attribute +import org.bukkit.command.CommandSender +import org.bukkit.command.ConsoleCommandSender +import org.bukkit.command.TabCompleter +import org.bukkit.enchantments.Enchantment +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.EnchantmentStorageMeta +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.event.HandlerList +import org.winlogon.powertools.suggestions.EnchantmentSuggestions +import org.winlogon.asynccraftr.AsyncCraftr + +import io.papermc.paper.datacomponent.DataComponentTypes +import io.papermc.paper.registry.RegistryAccess +import io.papermc.paper.registry.RegistryKey +import io.papermc.paper.registry.TypedKey + +import kotlin.math.roundToInt +import java.time.Duration +import de.tr7zw.changeme.nbtapi.NBT + +class PowerToolsPlugin : JavaPlugin() { + private lateinit var config: Configuration + private lateinit var absorbAnimal: AbsorbAnimal + private lateinit var lamp: Lamp + private val romanNumeralRegex = """^(?=.)M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$""".toRegex() + + override fun onEnable() { + if (!NBT.preloadApi()) { + logger.warning("NBT API not found") + server.pluginManager.disablePlugin(this) + return + } + config = loadConfig() + absorbAnimal = AbsorbAnimal(this) + + val sudoCommands = SudoCommands(this) + lamp = BukkitLamp.builder(this).build() + lamp.apply { + register(this@PowerToolsPlugin) + register(sudoCommands) + } + } + + private fun loadConfig(): Configuration { + saveDefaultConfig() + reloadConfig() + val yamlConfig = getConfig() + + val healCfg = HealConfig( + removeEffects = yamlConfig.getBoolean("heal.remove-effects", true), + showWhoHealed = yamlConfig.getBoolean("heal.show-who-healed", false) + ) + val unenchantCfg = UnenchantConfig(yamlConfig.getDouble("unenchant.base-price", 5.0)) + val unsafeCfg = UnsafeEnchantConfig(yamlConfig.getBoolean("unsafe-enchants.enabled", true)) + val transferCfg = TransferConfig( + enabled = yamlConfig.getBoolean("transfer.enabled", true), + servers = yamlConfig.getMapList("transfer.servers").map { configMap -> + val map = HashMap() + configMap.forEach { (k, v) -> map[k.toString()] = v.toString() } + map + } + ) + + return Configuration(healCfg, unenchantCfg, unsafeCfg, transferCfg) + } + + @Command("broadcast", "bc") + fun broadcast( + actor: BukkitCommandActor, + @Named("message") message: String + ) { + val formattedMessage = "[Broadcast] " + val messageComponent = Placeholder.component("message", Component.text(message, NamedTextColor.GRAY)) + + Bukkit.getOnlinePlayers().forEach { it.sendRichMessage(formattedMessage, messageComponent) } + Bukkit.getConsoleSender().sendRichMessage(formattedMessage, messageComponent) + } + + @Command("hat") + fun hat(actor: BukkitCommandActor) { + val player = actor.sender() as Player + + val inv = player.inventory + val hand = inv.itemInMainHand + val helmet = inv.helmet + + if (helmet != null) { + inv.helmet = hand + inv.setItemInMainHand(helmet) + player.sendRichMessage("Swapping items...") + } else { + inv.helmet = hand + inv.setItemInMainHand(null) + } + + player.updateInventory() + player.sendRichMessage("Your held item is now on your head!") + } + + @Command("invsee") + fun invsee( + actor: BukkitCommandActor, + @Named("target") target: Player + ) { + if (target.name == actor.name()) { + ChatFormatting.sendError(actor.sender(), "you cannot invsee yourself") + return + } + + val player = actor.sender() as Player + + if (!target.isOnline) { + ChatFormatting.sendError(player, "player not found or offline") + return + } + + val targetInv = target.inventory + val inventorySize = maxOf(45, targetInv.size) + + val wrapper = Bukkit.createInventory(null, inventorySize) + wrapper.contents = targetInv.contents + + player.openInventory(wrapper) + + // TODO: sync changes back into target’s inventory when closing widget + } + + @Command("absorb") + fun absorb(actor: BukkitCommandActor) { + val player = actor.sender() as Player + player.sendRichMessage("You have 5 seconds to right-click your pet to absorb it.") + + val listener = ClickListener(this, player) + server.pluginManager.registerEvents(listener, this) + + AsyncCraftr.runEntityTaskLater(this, player, Runnable { + HandlerList.unregisterAll(listener) + player.sendRichMessage("Absorb window expired.") + }, Duration.ofSeconds(5)) + } + + @Command("smite") + fun smite( + actor: BukkitCommandActor, + @Named("target") target: Player + ) { + val player = actor.sender() + target.takeIf { it.isOnline }?.let { tgt -> + try { + tgt.world.strikeLightning(target.location) + player.sendRichMessage("You have smitten ${target.name}!") + target.sendRichMessage("You have been smitten by a mighty force!") + } catch (e: Exception) { + ChatFormatting.sendError(player, "failed to smite player - ${e.message}") + } + } ?: ChatFormatting.sendError(player, "player not found or offline") + } + + @Command("sudo") + class SudoCommands(private val plugin: PowerToolsPlugin) { + @Subcommand("cmd") + fun sudoCommand( + actor: BukkitCommandActor, + @Named("target") target: Player, + @Named("command") command: String + ) { + if (!target.isOnline) { + ChatFormatting.sendError(actor.sender(), "player not found or offline") + return + } + AsyncCraftr.runEntityTask(plugin, target, Runnable { target.chat("/${command.trim()}") }) + } + + @Subcommand("echo") + fun sudoChat( + actor: BukkitCommandActor, + @Named("target") target: Player, + @Named("message") message: String + ) { + if (!target.isOnline) { + ChatFormatting.sendError(actor.sender(), "player not found or offline") + return + } + + target.chat(message) + } + } + + @Command("split", "unenchant") + fun split(actor: BukkitCommandActor) { + val player = actor.sender() as Player + + val inventory = player.inventory + val itemHand = inventory.itemInMainHand + + if (itemHand.type == Material.ENCHANTED_BOOK) { + ChatFormatting.sendError(player, "the item can't be an enchanted book") + return + } + + if (itemHand.type == Material.AIR) { + ChatFormatting.sendError(player, "you must be holding an item") + return + } + + val enchantments = itemHand.enchantments + if (enchantments.isEmpty()) { + ChatFormatting.sendError(player, "this item has no enchantments to split") + return + } + + val cost = (config.unenchant.basePrice * enchantments.size).roundToInt() + if (player.totalExperience < cost) { + ChatFormatting.sendError(player, "you need at least $cost XP to split these enchantments") + return + } + player.giveExp(-cost) + + val meta = itemHand.itemMeta + enchantments.keys.forEach(meta::removeEnchant) + itemHand.itemMeta = meta + itemHand.resetData(DataComponentTypes.REPAIR_COST) + + enchantments.forEach { (ench, level) -> + val book = ItemStack(Material.ENCHANTED_BOOK) + val bookMeta = book.itemMeta as EnchantmentStorageMeta + bookMeta.addStoredEnchant(ench, level, true) + book.itemMeta = bookMeta + + if (inventory.firstEmpty() == -1) { + player.world.dropItemNaturally(player.location, book) + } else { + inventory.addItem(book) + } + } + + player.updateInventory() + player.sendRichMessage( + "Successfully split enchantment(s) for XP.", + Placeholder.component("enchantments", Component.text(enchantments.size.toString(), NamedTextColor.DARK_AQUA)), + Placeholder.component("cost", Component.text(cost.toString(), NamedTextColor.GREEN)) + ) + } + + @Command("fly") + fun fly(actor: BukkitCommandActor) { + val player = actor.sender() as Player + val canFly = player.allowFlight + val toggledFly = !canFly + + val statusMessage = if (toggledFly) "enabled" else "disabled" + val color = if (toggledFly) "green" else "red" + val playerName = "${player.name}" + + if (player.gameMode == GameMode.CREATIVE) { + player.allowFlight = true + ChatFormatting.sendError(player, "flying is always disabled in creative mode. Keeping fly enabled") + return + } + player.allowFlight = toggledFly + player.sendRichMessage("Fly <$color>$statusMessage for $playerName.") + } + + @Command("ue", "unsafe-ench", "uenchant") + fun enchantUnsafe( + actor: BukkitCommandActor, + @SuggestWith(EnchantmentSuggestions::class) @Named("enchantment") enchant: String, + @Named("level") level: Int + ) { + // this should be precomputed somewhere + val unsafeEnchantsPrefix = ChatFormatting.colorConverter.convertToComponent("&8[&5UE&8]") + // context + val registry = RegistryAccess.registryAccess().getRegistry(RegistryKey.ENCHANTMENT) + val typedKey = TypedKey.create(RegistryKey.ENCHANTMENT, Key.key("minecraft:$enchant")) + + val enchantment = registry.get(typedKey) ?: run { + ChatFormatting.sendError(actor.sender(), "invalid enchantment, or not found") + return + } + + val player = actor.sender() as Player + + if (!config.unsafeEnchants.enabled) { + ChatFormatting.sendError(player, "unsafe enchantments are disabled in config") + return + } + + val item = player.inventory.itemInMainHand + if (item.type == Material.AIR) { + ChatFormatting.sendError(player, "you must be holding an item to enchant") + return + } + + item.addUnsafeEnchantment(enchantment, level) + player.updateInventory() + + val displayName = PlainTextComponentSerializer.plainText() + .serialize(enchantment.displayName(1)) + + val finalDisplayName = displayName.trim().split("\\s+".toRegex()).let { words -> + if (words.isNotEmpty() && romanNumeralRegex.matches(words.last())) { + words.dropLast(1).joinToString(" ") + } else { + displayName + } + } + + val finalDisplayNameComponent = Component.text(finalDisplayName, NamedTextColor.DARK_AQUA) + val levelComponent = Component.text(level.toString(), NamedTextColor.DARK_GREEN) + + player.sendRichMessage( + " Applied at level .", + Placeholder.component("prefix", unsafeEnchantsPrefix), + Placeholder.component("ench-display-name", finalDisplayNameComponent), + Placeholder.component("level", levelComponent) + ) + } + + @Command("heal", "h") + fun heal( + actor: BukkitCommandActor, + @Optional @Named("player") target: Player? + ) { + val healTarget = target ?: (actor.sender() as? Player) + healTarget?.let { healPlayer(it, actor.sender()) } + } + + private fun healPlayer(player: Player, sender: CommandSender) { + player.getAttribute(Attribute.MAX_HEALTH)?.let { + player.health = it.value + } + player.foodLevel = 20 + player.saturation = 20f + + if (config.heal.removeEffects) { + player.activePotionEffects.forEach { effect -> player.removePotionEffect(effect.type) } + } + + val healedMessage = "You have been healed." + + if (player != sender) { + sender.sendRichMessage( + " has been healed.", + Placeholder.component("player", Component.text(player.name)) + ) + + val healMsg = if (config.heal.showWhoHealed) { + "You have been healed by ." + } else { + healedMessage + } + + player.sendRichMessage(healMsg, Placeholder.component("staff", Component.text(sender.name))) + } else { + sender.sendRichMessage(healedMessage) + } + } +} + +// Configuration classes +data class UnenchantConfig(val basePrice: Double) +data class UnsafeEnchantConfig(val enabled: Boolean) +data class HealConfig(val removeEffects: Boolean, val showWhoHealed: Boolean) +data class TransferConfig(val enabled: Boolean, val servers: List>) +data class Configuration( + val heal: HealConfig, + val unenchant: UnenchantConfig, + val unsafeEnchants: UnsafeEnchantConfig, + val transferConfig: TransferConfig +) diff --git a/src/main/kotlin/org/winlogon/powertools/suggestions/EnchantmentSuggestions.kt b/src/main/kotlin/org/winlogon/powertools/suggestions/EnchantmentSuggestions.kt new file mode 100644 index 0000000..e5aea47 --- /dev/null +++ b/src/main/kotlin/org/winlogon/powertools/suggestions/EnchantmentSuggestions.kt @@ -0,0 +1,22 @@ +package org.winlogon.powertools.suggestions + +import revxrsal.commands.bukkit.actor.BukkitCommandActor +import revxrsal.commands.autocomplete.SuggestionProvider +import revxrsal.commands.node.ExecutionContext + +import org.bukkit.enchantments.Enchantment + +import io.papermc.paper.registry.RegistryAccess +import io.papermc.paper.registry.RegistryKey + +class EnchantmentSuggestions : SuggestionProvider { + override fun getSuggestions(context: ExecutionContext): List { + + val registry = RegistryAccess.registryAccess().getRegistry(RegistryKey.ENCHANTMENT) + return registry.stream() + .map { it.key.key } + .sorted() + .toList() + } +} + diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index fe48f87..cc91ee3 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -1,17 +1,7 @@ -name: 'PowerTools' -version: '0.4.0-SNAPSHOT' -main: 'org.winlogon.powertools.PowerToolsPlugin' -loader: 'org.winlogon.powertools.PowerToolsLoader' +name: '${NAME}' +version: '${VERSION}' +main: '${PACKAGE}.PowerToolsPlugin' +loader: '${PACKAGE}.PowerToolsLoader' description: 'Fun utilities for everyone!' folia-supported: true -api-version: 1.21 -dependencies: - server: - CommandAPI: - load: BEFORE - required: true - join-classpath: true - ScalaLoader: - load: BEFORE - required: true - join-classpath: true +api-version: 1.21.6 diff --git a/src/main/scala/org/winlogon/powertools/ChatFormatting.scala b/src/main/scala/org/winlogon/powertools/ChatFormatting.scala deleted file mode 100644 index 017490f..0000000 --- a/src/main/scala/org/winlogon/powertools/ChatFormatting.scala +++ /dev/null @@ -1,22 +0,0 @@ -package org.winlogon.powertools - -import org.winlogon.retrohue.RetroHue - -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver -import net.kyori.adventure.text.minimessage.tag.standard.StandardTags - -object ChatFormatting { - private val miniMessage = MiniMessage.miniMessage() - private val colorConverter = RetroHue(miniMessage) - private val tagsResolver: TagResolver = TagResolver - .builder() - .resolver(StandardTags.defaults()) - .build() - - /** Convert a legacy formatted string into a MiniMessage Component */ - def apply(msg: String): Component = { - colorConverter.convertToComponent(msg, '&') - } -} diff --git a/src/main/scala/org/winlogon/powertools/PowerToolsLoader.scala b/src/main/scala/org/winlogon/powertools/PowerToolsLoader.scala deleted file mode 100644 index 2e03432..0000000 --- a/src/main/scala/org/winlogon/powertools/PowerToolsLoader.scala +++ /dev/null @@ -1,40 +0,0 @@ -package org.winlogon.powertools - -import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver -import io.papermc.paper.plugin.loader.PluginClasspathBuilder -import io.papermc.paper.plugin.loader.PluginLoader - -import org.eclipse.aether.artifact.DefaultArtifact -import org.eclipse.aether.graph.Dependency -import org.eclipse.aether.repository.RemoteRepository - -class PowerToolsLoader extends PluginLoader { - override def classloader(classpathBuilder: PluginClasspathBuilder): Unit = { - val resolver = MavenLibraryResolver() - - resolver.addRepository( - RemoteRepository.Builder( - "central", - "default", - MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR - ).build() - ) - - resolver.addRepository( - RemoteRepository.Builder( - "winlogon-code", - "default", - "https://maven.winlogon.org/releases" - ).build() - ) - - resolver.addDependency( - Dependency( - DefaultArtifact("org.winlogon:retrohue:0.1.1"), - null - ) - ) - - classpathBuilder.addLibrary(resolver) - } -} diff --git a/src/main/scala/org/winlogon/powertools/PowerToolsPlugin.scala b/src/main/scala/org/winlogon/powertools/PowerToolsPlugin.scala deleted file mode 100644 index 8c557f7..0000000 --- a/src/main/scala/org/winlogon/powertools/PowerToolsPlugin.scala +++ /dev/null @@ -1,463 +0,0 @@ -package org.winlogon.powertools - -import dev.jorel.commandapi.CommandAPICommand -import dev.jorel.commandapi.arguments.{ - EnchantmentArgument, GreedyStringArgument, - IntegerArgument, PlayerArgument, StringArgument, - ArgumentSuggestions -} -import dev.jorel.commandapi.executors.{CommandArguments, CommandExecutor} - -import org.bukkit.attribute.Attribute -import org.bukkit.command.{CommandSender, ConsoleCommandSender} -import org.bukkit.enchantments.Enchantment -import org.bukkit.entity.Player -import org.bukkit.inventory.ItemStack -import org.bukkit.inventory.meta.EnchantmentStorageMeta -import org.bukkit.plugin.java.JavaPlugin -import org.bukkit.{Bukkit, Material} - -import io.papermc.paper.chat.ChatRenderer -import io.papermc.paper.datacomponent.DataComponentTypes -import io.papermc.paper.event.player.AsyncChatEvent - -import net.kyori.adventure.audience.Audience -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer - -import scala.jdk.CollectionConverters.* -import scala.util.{Try, Success, Failure} -import scala.util.boundary - -case class UnenchantConfig(basePrice: Double) -case class UnsafeEnchantConfig(enabled: Boolean) -case class HealConfig(removeEffects: Boolean, showWhoHealed: Boolean) -case class TransferConfig(enabled: Boolean, servers: List[java.util.HashMap[String, String]]) -case class Configuration( - heal: HealConfig, - unenchant: UnenchantConfig, - unsafeEnchants: UnsafeEnchantConfig, - transferConfig: TransferConfig -) - -class PowerToolsPlugin extends JavaPlugin { - private var config: Configuration = _ - - override def onEnable(): Unit = { - config = loadConfig() - registerCommands() - } - - private def loadConfig(): Configuration = { - saveDefaultConfig() - reloadConfig() - val yamlConfig = getConfig - - val healCfg = HealConfig( - removeEffects = yamlConfig.getBoolean("heal.remove-effects", true), - showWhoHealed = yamlConfig.getBoolean("heal.show-who-healed", false) - ) - val unenchantCfg = UnenchantConfig(yamlConfig.getDouble("unenchant.base-price", 5.0)) - val unsafeCfg = UnsafeEnchantConfig(yamlConfig.getBoolean("unsafe-enchants.enabled", true)) - val transferCfg = TransferConfig( - enabled = yamlConfig.getBoolean("transfer.enabled", true), - servers = yamlConfig.getMapList("transfer.servers").asScala.map { configMap => - val map = new java.util.HashMap[String, String]() - configMap.asScala.foreach { case (k, v) => - map.put(k.toString, v.toString) - } - map - }.toList - ) - - Configuration(healCfg, unenchantCfg, unsafeCfg, transferCfg) - } - - // TODO: add user-facing /transfer command which is configurable - private def registerCommands(): Unit = { - // Helper to always return 1 as status. - val successStatus: Int = 1 - - // Broadcast Command - CommandAPICommand("broadcast") - .withAliases("bc") - .withPermission("powertools.broadcast") - .withArguments(GreedyStringArgument("message")) - .executes((sender: CommandSender, args: CommandArguments) => { - val message = args.get("message").asInstanceOf[String] - executeBroadcast(sender, message) - successStatus - }) - .register() - - // Hat command - CommandAPICommand("hat") - .withPermission("powertools.hat") - .executesPlayer((player: Player, _: CommandArguments) => { - executeHat(player) - successStatus - }) - .register() - - // Invsee Command - CommandAPICommand("invsee") - .withPermission("powertools.invsee") - .withArguments(PlayerArgument("target")) - .executesPlayer((player: Player, args: CommandArguments) => { - val targetName = Option(args.get("target").asInstanceOf[Player]).map(_.getName) - .getOrElse("unknown") - - if (!targetName.equalsIgnoreCase(player.getName)) { - executeInvsee(player, targetName) - } else { - sendError(player, "You cannot invsee yourself.") - } - - successStatus - }) - .register() - - // Smite Command - CommandAPICommand("smite") - .withPermission("powertools.smite") - .withArguments(PlayerArgument("target")) - .executes((sender: CommandSender, args: CommandArguments) => { - val player = args.get("target").asInstanceOf[Player] - val targetName = Option(player).map(_.getName).getOrElse("unknown") - executeSmite(sender, targetName) - successStatus - }) - .register() - - // Sudo command - CommandAPICommand("sudo") - .withAliases("doas") - .withPermission("powertools.wheel") - .withArguments(StringArgument("target")) - .withSubcommand( - CommandAPICommand("command") - .withAliases("cmd") - .withArguments(PlayerArgument("target")) - .withArguments(GreedyStringArgument("command")) - .executes((sender: CommandSender, args: CommandArguments) => { - val target = args.get("target").asInstanceOf[Player] - val command = args.get("command").asInstanceOf[String] - executeSudoCommand(sender, target, command) - successStatus - }) - ) - .withSubcommand( - CommandAPICommand("chat") - .withArguments(PlayerArgument("target")) - .withArguments(GreedyStringArgument("message")) - .executes((sender: CommandSender, args: CommandArguments) => { - val target = args.get("target").asInstanceOf[Player] - val message = args.get("message").asInstanceOf[String] - executeSudoChat(sender, target, message) - successStatus - }) - ) - .register() - - // Unenchant Command - CommandAPICommand("splitenchants") - .withPermission("powertools.splitenchants") - .withAliases("split", "unenchant") - .executesPlayer((player: Player, _: CommandArguments) => { - executeSplitUnenchant(player) - successStatus - }) - .register() - - CommandAPICommand("fly") - .withPermission("powertools.fly") - .executesPlayer((player: Player, _: CommandArguments) => { - val canFly = player.getAllowFlight() - val toggledFly = !canFly - - val statusMessage = if (toggledFly) "enabled" else "disabled" - val color = if (toggledFly) "dark_aqua" else "red" - - val playerName = s"${player.getName()}" - player.setAllowFlight(toggledFly) - player.sendRichMessage(s"Fly <$color>$statusMessage for $playerName.") - successStatus - }) - .register() - - // Unsafe enchant command - CommandAPICommand("enchantunsafe") - .withPermission("powertools.unsafe-enchants") - .withAliases("ue", "uenchant") - .withArguments(EnchantmentArgument("enchantment"), IntegerArgument("level")) - .executesPlayer((player: Player, args: CommandArguments) => { - executeUnsafeEnchant(player, args) - successStatus - }) - .register() - - // Heal command - CommandAPICommand("heal") - .withPermission("heal.admin") - .withAliases("h") - .withArguments(PlayerArgument("player").setOptional(true)) - .executes((sender: CommandSender, args: CommandArguments) => { - val maybeTarget = Option(args.get("player").asInstanceOf[Player]) - .orElse(Option(sender).collect { case p: Player => p }) - maybeTarget.foreach(target => healPlayer(target, sender)) - successStatus - }) - .register() - - registerTransferCommandsIfEnabled(config.transferConfig.enabled) - } - private def executeUnsafeEnchant(player: Player, args: CommandArguments): Unit = { - if (!config.unsafeEnchants.enabled) { - sendError(player, "Unsafe enchantments are disabled in config.") - return - } - val enchantment = args.get("enchantment").asInstanceOf[Enchantment] - val level = args.get("level").asInstanceOf[Integer].intValue() - - Option(player.getInventory.getItemInMainHand) - .filter(item => item.getType != Material.AIR) - .fold(sendError(player, "You must be holding an item to enchant.")) { item => - item.addUnsafeEnchantment(enchantment, level) - player.updateInventory() - - val displayName = PlainTextComponentSerializer.plainText() - .serialize(enchantment.displayName(1)) - - val romanNumeralRegex = """^(?=.)M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$""".r - - val finalDisplayName = { - val trimmed = displayName.trim - val words = trimmed.split("\\s+") - if (words.nonEmpty && romanNumeralRegex.pattern.matcher(words.last).matches()) { - words.dropRight(1).mkString(" ") - } else { - displayName - } - } - - val prefix = "&8[&5UE&8]" - player.sendMessage(fmt( - s"$prefix Applied $finalDisplayName at level $level." - )) - } - } - - private def executeSplitUnenchant(player: Player): Unit = { - val inventory = player.getInventory - val itemHand = Option(inventory.getItemInMainHand) - - itemHand - .filter(_.getType == Material.ENCHANTED_BOOK) - .foreach { _ => - sendError(player, "The item can't be an enchanted book.") - return - } - - itemHand - .filterNot(item => item == null || item.getType == Material.AIR) - .fold(sendError(player, "You must be holding an item.")) { itemInHand => - val enchantments = itemInHand.getEnchantments - if (enchantments.isEmpty) { - sendError(player, "This item has no enchantments to split.") - return - } - val cost = (config.unenchant.basePrice * enchantments.size).toInt - if (player.getTotalExperience < cost) { - sendError(player, s"You need at least $cost XP to split these enchantments.") - return - } - player.giveExp(-cost) - - // Remove all enchantments from the item meta. - val meta = itemInHand.getItemMeta - enchantments.keySet.asScala.foreach(meta.removeEnchant) - itemInHand.setItemMeta(meta) - itemInHand.resetData(DataComponentTypes.REPAIR_COST) - - // Create an enchanted book for each enchantment. - enchantments.asScala.foreach { case (ench, level) => - val book = new ItemStack(Material.ENCHANTED_BOOK) - val bookMeta = book.getItemMeta.asInstanceOf[EnchantmentStorageMeta] - bookMeta.addStoredEnchant(ench, level, true) - book.setItemMeta(bookMeta) - // If the inventory is full, drop it at the player's position - if (inventory.firstEmpty() == -1) - player.getWorld.dropItemNaturally(player.getLocation, book) - else - inventory.addItem(book) - } - player.updateInventory() - player.sendMessage(fmt(s"&7Successfully split &3${enchantments.size} &7enchantment(s) for &2$cost XP.")) - } - } - - private def executeBroadcast(sender: CommandSender, message: String): Unit = { - val formattedMessage = "[Broadcast] " - val messageComponent = Placeholder.component("message", Component.text(message, NamedTextColor.GRAY)) - - Bukkit.getOnlinePlayers.forEach(_.sendRichMessage(formattedMessage, messageComponent)) - // Send message to console if the command was sent from console for user feedback - if (sender.isInstanceOf[ConsoleCommandSender]) { - sender.sendRichMessage(formattedMessage, messageComponent) - } - } - - private def executeSudoCommand(sender: CommandSender, target: Player, command: String): Unit = { - if (target == null || !target.isOnline) { - sender.sendMessage(fmt("<#F93822>Error&7: Player not found or offline.")) - return - } - // Use the scheduler to perform the command asynchronously. - target.getScheduler.execute(this, () => target.chat(s"/${command.trim()}"), null, 0L) - } - - private def executeSudoChat(sender: CommandSender, target: Player, message: String): Unit = { - if (target == null || !target.isOnline) { - sendError(sender, "Player not found or offline.") - return - } - val players = Bukkit.getOnlinePlayers() - val viewers = new java.util.HashSet[Audience](players) - val userMsg = fmt(message) - - // `AsyncChatEvent`s called by plugins must be synchronous - val event = AsyncChatEvent(false, target, viewers, ChatRenderer.defaultRenderer(), userMsg, userMsg, null) - - // If the event is not cancelled, send the message - if (event.callEvent() && !event.isCancelled) { - val renderer = event.renderer() - val senderComponent = event.getPlayer.displayName() - // Create an audience from the players and craft a new rendered message - val renderedMessage = renderer.render(event.getPlayer, senderComponent, event.message(), Audience.audience(players)) - event.viewers().forEach(_.sendMessage(renderedMessage)) - } - } - - private def executeHat(player: Player): Unit = { - val inv = player.getInventory - val hand = inv.getItemInMainHand - val helmet = Option(inv.getHelmet) - helmet match { - case Some(h) => - inv.setHelmet(hand) - inv.setItemInMainHand(h) - player.sendMessage(fmt("&7Swapping items...")) - case None => - inv.setHelmet(hand) - inv.setItemInMainHand(null) - } - player.updateInventory() - player.sendMessage(fmt("&7Your held item is now &3on your head!")) - } - - private def executeInvsee(player: Player, targetName: String): Unit = { - Option(Bukkit.getPlayer(targetName)) - .filter(_.isOnline) - .fold(sendError(player, "Player not found or offline."))(target => player.openInventory(target.getInventory)) - } - - private def executeSmite(sender: CommandSender, targetName: String): Unit = { - Option(Bukkit.getPlayer(targetName)) - .filter(_.isOnline) // if the player is online and is not null - // or else if he is, sendError, or else - .fold(sendError(sender, "Player not found or offline.")) { target => - val result = Try { - target.getWorld.strikeLightning(target.getLocation) - } - - result match { - case Success(_) => - sender.sendMessage(fmt(s"&7You have smitten &3${target.getName}!")) - target.sendMessage(fmt("&7You have been smitten by &3a mighty force!")) - case Failure(_) => - sendError(sender, "Failed to smite player.") - } - } - } - - private def healPlayer(player: Player, sender: CommandSender): Unit = { - Option(player.getAttribute(Attribute.MAX_HEALTH)) - .foreach(max => player.setHealth(max.getValue())) - player.setFoodLevel(20) - player.setSaturation(20f) - - if (config.heal.removeEffects) { - player.getActivePotionEffects.forEach(effect => player.removePotionEffect(effect.getType)) - } - - val healedMessage = "You have been healed." - - if (player != sender) { - sender.sendRichMessage( - " has been healed.", - Placeholder.component("player", Component.text(player.getName(), NamedTextColor.DARK_AQUA)) - ) - - val senderComponent = Component.text(sender.getName(), NamedTextColor.DARK_AQUA) - - val healMsg = if (config.heal.showWhoHealed) { - s"You have been healed by ." - } else { - healedMessage - } - - player.sendRichMessage(healMsg, Placeholder.component("staff", senderComponent)) - } else { - sender.sendRichMessage(healedMessage) - } - } - - private def registerTransferCommandsIfEnabled(transferConfigEnabled: Boolean): Unit = { - if (!transferConfigEnabled) { - return - } - - CommandAPICommand("transfer") - .withPermission("powertools.transfer") - .withArguments(new StringArgument("server").replaceSuggestions(ArgumentSuggestions.strings(info => { - config.transferConfig.servers.filter { server => - val allowPlayers = server.getOrDefault("allowPlayers", "false").toBoolean - allowPlayers || info.sender().hasPermission("powertools.transfer.all") - }.map(_.get("name")).toArray - }))) - .executesPlayer((player: Player, args: CommandArguments) => { - val serverName = args.get("server").asInstanceOf[String] - config.transferConfig.servers.find(_.get("name") == serverName) match { - case None => - sendError(player, "Server not found.") - case Some(server) => - val allowPlayers = server.getOrDefault("allowPlayers", "false").toBoolean - if (!(allowPlayers && player.hasPermission("powertools.transfer.all"))) { - sendError(player, "You don't have permission to transfer to this server.") - return - } - - try { - val host = server.get("host") - val port = server.get("port").toInt - player.transfer(host, port) - } catch { - case _: NumberFormatException => - sendError(player, "Invalid port for server. Please contact a server admin") - case ex: Exception => - sendError(player, s"Transfer failed: ${ex.getMessage}") - } - } - }) - .register() - } - - /** Format a string converting legacy colors to MiniMessage */ - private def fmt(s: String): Component = ChatFormatting.apply(s) - - /** Send an error message to the player */ - private def sendError(target: Player | CommandSender, err: String): Unit = - target.sendMessage(fmt(s"<#F93822>Error&7: $err")) -}