From 1248c33bebd7d6402b7e3e88c8c48a46762fb9ab Mon Sep 17 00:00:00 2001 From: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri, 1 May 2026 19:20:14 +0200 Subject: [PATCH] first commit --- .gitignore | 5 + README.md | 44 ++++ build.gradle | 56 ++++ gradle.properties | 0 gradlew | 249 ++++++++++++++++++ gradlew.bat | 92 +++++++ settings.gradle | 1 + .../fallSMPRewards/FallSMPRewards.java | 40 +++ .../command/RewardsCommand.java | 59 +++++ .../fallSMPRewards/gui/RewardsGui.java | 151 +++++++++++ .../listener/QuestListener.java | 118 +++++++++ .../fallSMPRewards/model/PlayerData.java | 78 ++++++ .../fallSMPRewards/model/QuestDefinition.java | 43 +++ .../fallSMPRewards/service/QuestService.java | 89 +++++++ .../storage/PlayerDataStore.java | 101 +++++++ src/main/resources/config.yml | 39 +++ src/main/resources/plugin.yml | 19 ++ 17 files changed, 1184 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/de/winniepat/fallSMPRewards/FallSMPRewards.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/command/RewardsCommand.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/gui/RewardsGui.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/listener/QuestListener.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/model/PlayerData.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/model/QuestDefinition.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/service/QuestService.java create mode 100644 src/main/java/de/winniepat/fallSMPRewards/storage/PlayerDataStore.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f9436c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle +.idea +build +gradle +run \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4946dca --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# FallSMP-Rewards + +Kill-based quest rewards plugin for Paper 1.21. + +## Features + +- Configurable player-kill milestone quests in `config.yml` +- Item rewards per quest (example format: `DIAMOND:3`) +- 3x3 GUI (`/rewards`) with: + - `CHEST` icon when claimable + - `MINECART` icon when not claimable + - detailed lore for quest text + progress + rewards +- JSON player progress storage in `plugins/FallSMP-Rewards/playerdata/.json` +- Per killer-victim cooldown for counted kills (default 10 minutes) +- Sound effects for open, progress, unlocked, claim, and denied actions + +## Command + +- `/rewards` - Opens the rewards GUI +- `/rewards reload` - Reloads plugin config and quest definitions (`fallsmprewards.reload`) + +## Configure quests + +Edit `src/main/resources/config.yml` (or generated plugin config): + +```yml +kill-count-cooldown-minutes: 10 + +quests: + hunter_1: + required-kills: 5 + title: "&aHunter I" + description: + - "&7Kill 5 players in total." + rewards: + - "IRON_INGOT:16" + - "GOLDEN_APPLE:1" +``` + +## Build + +```powershell +./gradlew.bat build +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2f7674f --- /dev/null +++ b/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'java' + id("xyz.jpenilla.run-paper") version "2.3.1" +} + +group = 'de.winniepat' +version = '1.1' + +repositories { + mavenCentral() + maven { + name = "papermc-repo" + url = "https://repo.papermc.io/repository/maven-public/" + } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("com.google.code.gson:gson:2.11.0") +} + +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.11") + } +} + +def targetJavaVersion = 21 +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + options.release.set(targetJavaVersion) + } +} + +processResources { + def props = [version: version] + inputs.properties props + filteringCharset 'UTF-8' + filesMatching('plugin.yml') { + expand props + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e69de29 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..b740cf1 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..652970c --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'FallSMP-Rewards' diff --git a/src/main/java/de/winniepat/fallSMPRewards/FallSMPRewards.java b/src/main/java/de/winniepat/fallSMPRewards/FallSMPRewards.java new file mode 100644 index 0000000..fce2c83 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/FallSMPRewards.java @@ -0,0 +1,40 @@ +package de.winniepat.fallSMPRewards; + +import de.winniepat.fallSMPRewards.command.RewardsCommand; +import de.winniepat.fallSMPRewards.gui.RewardsGui; +import de.winniepat.fallSMPRewards.listener.QuestListener; +import de.winniepat.fallSMPRewards.service.QuestService; +import de.winniepat.fallSMPRewards.storage.PlayerDataStore; +import java.util.Objects; +import org.bukkit.plugin.java.JavaPlugin; + +public final class FallSMPRewards extends JavaPlugin { + + private QuestService questService; + private PlayerDataStore playerDataStore; + private RewardsGui rewardsGui; + + @Override + public void onEnable() { + saveDefaultConfig(); + + questService = new QuestService(this); + questService.reload(); + + playerDataStore = new PlayerDataStore(this); + rewardsGui = new RewardsGui(this, questService, playerDataStore); + + getServer().getPluginManager().registerEvents(new QuestListener(this, questService, playerDataStore, rewardsGui), this); + Objects.requireNonNull(getCommand("rewards"), "rewards command missing in plugin.yml").setExecutor(new RewardsCommand(this, rewardsGui, questService)); + + getLogger().info("Enabled with " + questService.getQuests().size() + " kill quests."); + } + + @Override + public void onDisable() { + if (playerDataStore != null) { + playerDataStore.flush(); + } + getLogger().info("Disabled"); + } +} diff --git a/src/main/java/de/winniepat/fallSMPRewards/command/RewardsCommand.java b/src/main/java/de/winniepat/fallSMPRewards/command/RewardsCommand.java new file mode 100644 index 0000000..c432865 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/command/RewardsCommand.java @@ -0,0 +1,59 @@ +package de.winniepat.fallSMPRewards.command; + +import de.winniepat.fallSMPRewards.FallSMPRewards; +import de.winniepat.fallSMPRewards.gui.RewardsGui; +import de.winniepat.fallSMPRewards.service.QuestService; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public final class RewardsCommand implements CommandExecutor { + + private static final String USE_PERMISSION = "fallsmprewards.use"; + private static final String RELOAD_PERMISSION = "fallsmprewards.reload"; + + private final FallSMPRewards plugin; + private final RewardsGui rewardsGui; + private final QuestService questService; + + public RewardsCommand(FallSMPRewards plugin, RewardsGui rewardsGui, QuestService questService) { + this.plugin = plugin; + this.rewardsGui = rewardsGui; + this.questService = questService; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length > 0) { + if (args[0].equalsIgnoreCase("reload")) { + if (!sender.hasPermission(RELOAD_PERMISSION)) { + sender.sendMessage(ChatColor.RED + "You do not have permission to reload this plugin."); + return true; + } + + plugin.reloadConfig(); + questService.reload(); + sender.sendMessage(ChatColor.GREEN + "FallSMP-Rewards config reloaded."); + return true; + } + + sender.sendMessage(ChatColor.RED + "Unknown subcommand. Use /" + label + " or /" + label + " reload"); + return true; + } + + if (!(sender instanceof Player player)) { + sender.sendMessage(ChatColor.RED + "Only players can open the rewards GUI. Use /" + label + " reload from console."); + return true; + } + + if (!sender.hasPermission(USE_PERMISSION)) { + sender.sendMessage(ChatColor.RED + "You do not have permission to use this command."); + return true; + } + + rewardsGui.open(player); + return true; + } +} diff --git a/src/main/java/de/winniepat/fallSMPRewards/gui/RewardsGui.java b/src/main/java/de/winniepat/fallSMPRewards/gui/RewardsGui.java new file mode 100644 index 0000000..d96f782 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/gui/RewardsGui.java @@ -0,0 +1,151 @@ +package de.winniepat.fallSMPRewards.gui; + +import de.winniepat.fallSMPRewards.FallSMPRewards; +import de.winniepat.fallSMPRewards.model.PlayerData; +import de.winniepat.fallSMPRewards.model.QuestDefinition; +import de.winniepat.fallSMPRewards.service.QuestService; +import de.winniepat.fallSMPRewards.storage.PlayerDataStore; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +public final class RewardsGui { + + private final FallSMPRewards plugin; + private final QuestService questService; + private final PlayerDataStore playerDataStore; + + public RewardsGui(FallSMPRewards plugin, QuestService questService, PlayerDataStore playerDataStore) { + this.plugin = plugin; + this.questService = questService; + this.playerDataStore = playerDataStore; + } + + public void open(Player player) { + Inventory inventory = Bukkit.createInventory(null, 9, getTitle()); + PlayerData data = playerDataStore.get(player.getUniqueId()); + List quests = questService.getQuests(); + + for (int slot = 0; slot < Math.min(9, quests.size()); slot++) { + QuestDefinition quest = quests.get(slot); + inventory.setItem(slot, buildQuestIcon(quest, data)); + } + + player.openInventory(inventory); + player.playSound(player.getLocation(), resolveSound("sounds.open", "block.chest.open"), 1f, 1f); + } + + public boolean isRewardsInventoryTitle(String title) { + return getTitle().equals(title); + } + + public boolean claim(Player player, int slot) { + List quests = questService.getQuests(); + if (slot < 0 || slot >= quests.size() || slot >= 9) { + return false; + } + + QuestDefinition quest = quests.get(slot); + PlayerData data = playerDataStore.get(player.getUniqueId()); + if (data.hasClaimed(quest.getId()) || data.getTotalKills() < quest.getRequiredKills()) { + return false; + } + + if (!playerDataStore.claim(player.getUniqueId(), quest.getId())) { + return false; + } + + for (ItemStack reward : quest.getRewards()) { + player.getInventory().addItem(reward).values().forEach(leftover -> + player.getWorld().dropItemNaturally(player.getLocation(), leftover)); + } + + player.sendMessage(color("&aQuest reward claimed: &f" + quest.getTitle())); + player.playSound(player.getLocation(), resolveSound("sounds.claim", "entity.player.levelup"), 1f, 1.1f); + open(player); + return true; + } + + private ItemStack buildQuestIcon(QuestDefinition quest, PlayerData data) { + boolean claimed = data.hasClaimed(quest.getId()); + boolean claimable = !claimed && data.getTotalKills() >= quest.getRequiredKills(); + + Material material = claimable ? Material.CHEST_MINECART : Material.MINECART; + if (claimed) { + material = Material.BARRIER; + } + + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + meta.setDisplayName(quest.getTitle()); + + List lore = new ArrayList<>(); + lore.addAll(quest.getDescription()); + lore.add(""); + lore.add(color("&7Progress: &f" + Math.min(data.getTotalKills(), quest.getRequiredKills()) + "&7/&f" + quest.getRequiredKills())); + lore.add(color("&7Reward:")); + for (ItemStack reward : quest.getRewards()) { + lore.add(color("&f- " + reward.getAmount() + "x " + prettify(reward.getType().name()))); + } + lore.add(""); + + if (claimed) { + lore.add(color("&aAlready claimed")); + } else if (claimable) { + lore.add(color("&eClick to claim!")); + } else { + lore.add(color("&cNot claimable yet")); + } + + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } + + private String prettify(String text) { + String lower = text.toLowerCase().replace('_', ' '); + String[] parts = lower.split(" "); + StringBuilder builder = new StringBuilder(); + for (String part : parts) { + if (part.isEmpty()) { + continue; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return builder.toString(); + } + + private String getTitle() { + return color(plugin.getConfig().getString("gui.title", "&6Kill Rewards")); + } + + private String resolveSound(String path, String fallback) { + String configured = plugin.getConfig().getString(path, fallback); + if (configured == null || configured.isBlank()) { + return fallback; + } + String normalized = configured.trim().toLowerCase(); + // Support old enum-style names like BLOCK_CHEST_OPEN. + if (!normalized.contains(".")) { + normalized = normalized.replace('_', '.'); + } + return normalized; + } + + private String color(String input) { + return ChatColor.translateAlternateColorCodes('&', input); + } +} diff --git a/src/main/java/de/winniepat/fallSMPRewards/listener/QuestListener.java b/src/main/java/de/winniepat/fallSMPRewards/listener/QuestListener.java new file mode 100644 index 0000000..e30e192 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/listener/QuestListener.java @@ -0,0 +1,118 @@ +package de.winniepat.fallSMPRewards.listener; + +import de.winniepat.fallSMPRewards.FallSMPRewards; +import de.winniepat.fallSMPRewards.gui.RewardsGui; +import de.winniepat.fallSMPRewards.model.PlayerData; +import de.winniepat.fallSMPRewards.model.QuestDefinition; +import de.winniepat.fallSMPRewards.service.QuestService; +import de.winniepat.fallSMPRewards.storage.PlayerDataStore; +import de.winniepat.fallSMPRewards.storage.PlayerDataStore.KillAddResult; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; + +public final class QuestListener implements Listener { + + private final FallSMPRewards plugin; + private final QuestService questService; + private final PlayerDataStore playerDataStore; + private final RewardsGui rewardsGui; + + public QuestListener(FallSMPRewards plugin, QuestService questService, PlayerDataStore playerDataStore, RewardsGui rewardsGui) { + this.plugin = plugin; + this.questService = questService; + this.playerDataStore = playerDataStore; + this.rewardsGui = rewardsGui; + } + + @EventHandler + public void onPlayerDeath(PlayerDeathEvent event) { + Player victim = event.getEntity(); + Player killer = victim.getKiller(); + if (killer == null || killer.getUniqueId().equals(victim.getUniqueId())) { + return; + } + + long cooldownMillis = getCooldownMillis(); + KillAddResult killResult = playerDataStore.addKillWithCooldown(killer.getUniqueId(), victim.getUniqueId(), cooldownMillis); + if (!killResult.counted()) { + killer.sendMessage(color("&cThis kill does not count for rewards. Kill this player again in &f" + formatDuration(killResult.remainingCooldownMillis()) + "&c.")); + killer.playSound(killer.getLocation(), resolveSound("sounds.denied", "block.note_block.bass"), 1f, 1f); + return; + } + + int totalKills = killResult.totalKills(); + PlayerData data = playerDataStore.get(killer.getUniqueId()); + boolean unlocked = false; + for (QuestDefinition quest : questService.getQuests()) { + if (!data.hasClaimed(quest.getId()) && totalKills >= quest.getRequiredKills() && totalKills - 1 < quest.getRequiredKills()) { + killer.sendMessage(color("&6Quest ready to claim: &f" + quest.getTitle())); + unlocked = true; + } + } + + if (unlocked) { + killer.playSound(killer.getLocation(), resolveSound("sounds.unlocked", "ui.toast.challenge_complete"), 1f, 1f); + } else { + killer.playSound(killer.getLocation(), resolveSound("sounds.progress", "entity.experience_orb.pickup"), 0.6f, 1.2f); + } + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!rewardsGui.isRewardsInventoryTitle(event.getView().getTitle())) { + return; + } + + event.setCancelled(true); + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + if (event.getClickedInventory() == null || event.getRawSlot() < 0 || event.getRawSlot() >= 9) { + return; + } + + boolean claimed = rewardsGui.claim(player, event.getRawSlot()); + if (!claimed) { + player.playSound(player.getLocation(), resolveSound("sounds.denied", "block.note_block.bass"), 1f, 1f); + } + } + + private long getCooldownMillis() { + long cooldownMinutes = Math.max(1L, plugin.getConfig().getLong("kill-count-cooldown-minutes", 10L)); + return cooldownMinutes * 60_000L; + } + + private String formatDuration(long remainingMillis) { + long totalSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L); + long minutes = totalSeconds / 60L; + long seconds = totalSeconds % 60L; + if (minutes == 0L) { + return seconds + "s"; + } + if (seconds == 0L) { + return minutes + "m"; + } + return minutes + "m " + seconds + "s"; + } + + private String resolveSound(String path, String fallback) { + String configured = plugin.getConfig().getString(path, fallback); + if (configured == null || configured.isBlank()) { + return fallback; + } + String normalized = configured.trim().toLowerCase(); + if (!normalized.contains(".")) { + normalized = normalized.replace('_', '.'); + } + return normalized; + } + + private String color(String input) { + return ChatColor.translateAlternateColorCodes('&', input); + } +} diff --git a/src/main/java/de/winniepat/fallSMPRewards/model/PlayerData.java b/src/main/java/de/winniepat/fallSMPRewards/model/PlayerData.java new file mode 100644 index 0000000..a2fc8db --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/model/PlayerData.java @@ -0,0 +1,78 @@ +package de.winniepat.fallSMPRewards.model; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class PlayerData { + + private UUID uuid; + private int totalKills; + private Set claimedQuestIds; + private Map lastCountedKillByVictim; + + public PlayerData() { + // Required for Gson. + } + + public PlayerData(UUID uuid) { + this.uuid = uuid; + this.totalKills = 0; + this.claimedQuestIds = new HashSet<>(); + this.lastCountedKillByVictim = new HashMap<>(); + } + + public void sanitize(UUID expectedUuid) { + if (uuid == null) { + uuid = expectedUuid; + } + if (claimedQuestIds == null) { + claimedQuestIds = new HashSet<>(); + } + if (lastCountedKillByVictim == null) { + lastCountedKillByVictim = new HashMap<>(); + } + if (totalKills < 0) { + totalKills = 0; + } + } + + public UUID getUuid() { + return uuid; + } + + public int getTotalKills() { + return totalKills; + } + + public int addKill() { + totalKills++; + return totalKills; + } + + public long getRemainingCooldownMillis(UUID victimUuid, long cooldownMillis, long nowMillis) { + Long lastCountedMillis = lastCountedKillByVictim.get(victimUuid.toString()); + if (lastCountedMillis == null) { + return 0L; + } + long elapsed = nowMillis - lastCountedMillis; + if (elapsed >= cooldownMillis) { + return 0L; + } + return cooldownMillis - elapsed; + } + + public void setLastCountedKill(UUID victimUuid, long nowMillis) { + lastCountedKillByVictim.put(victimUuid.toString(), nowMillis); + } + + public boolean hasClaimed(String questId) { + return claimedQuestIds.contains(questId); + } + + public boolean claim(String questId) { + return claimedQuestIds.add(questId); + } +} diff --git a/src/main/java/de/winniepat/fallSMPRewards/model/QuestDefinition.java b/src/main/java/de/winniepat/fallSMPRewards/model/QuestDefinition.java new file mode 100644 index 0000000..edb9713 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/model/QuestDefinition.java @@ -0,0 +1,43 @@ +package de.winniepat.fallSMPRewards.model; + +import java.util.Collections; +import java.util.List; +import org.bukkit.inventory.ItemStack; + +public final class QuestDefinition { + + private final String id; + private final String title; + private final List description; + private final int requiredKills; + private final List rewards; + + public QuestDefinition(String id, String title, List description, int requiredKills, List rewards) { + this.id = id; + this.title = title; + this.description = List.copyOf(description); + this.requiredKills = requiredKills; + this.rewards = rewards.stream().map(ItemStack::clone).toList(); + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public List getDescription() { + return Collections.unmodifiableList(description); + } + + public int getRequiredKills() { + return requiredKills; + } + + public List getRewards() { + return rewards.stream().map(ItemStack::clone).toList(); + } +} + diff --git a/src/main/java/de/winniepat/fallSMPRewards/service/QuestService.java b/src/main/java/de/winniepat/fallSMPRewards/service/QuestService.java new file mode 100644 index 0000000..1b0a4e2 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/service/QuestService.java @@ -0,0 +1,89 @@ +package de.winniepat.fallSMPRewards.service; + +import de.winniepat.fallSMPRewards.FallSMPRewards; +import de.winniepat.fallSMPRewards.model.QuestDefinition; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.inventory.ItemStack; + +public final class QuestService { + + private final FallSMPRewards plugin; + private final List quests = new ArrayList<>(); + + public QuestService(FallSMPRewards plugin) { + this.plugin = plugin; + } + + public void reload() { + plugin.reloadConfig(); + quests.clear(); + + ConfigurationSection questSection = plugin.getConfig().getConfigurationSection("quests"); + if (questSection == null) { + plugin.getLogger().warning("No quests section found in config.yml"); + return; + } + + for (String questId : questSection.getKeys(false)) { + ConfigurationSection section = questSection.getConfigurationSection(questId); + if (section == null) { + continue; + } + + int requiredKills = Math.max(1, section.getInt("required-kills", 1)); + String title = color(section.getString("title", "&e" + questId)); + List description = section.getStringList("description").stream().map(this::color).toList(); + List rewards = parseRewards(section.getStringList("rewards")); + + if (rewards.isEmpty()) { + plugin.getLogger().warning("Quest " + questId + " has no valid rewards and was skipped."); + continue; + } + + quests.add(new QuestDefinition(questId, title, description, requiredKills, rewards)); + } + + if (quests.size() > 9) { + plugin.getLogger().warning("More than 9 quests configured. GUI shows only the first 9 quests."); + } + } + + public List getQuests() { + return Collections.unmodifiableList(quests); + } + + private List parseRewards(List rawRewards) { + List rewards = new ArrayList<>(); + for (String raw : rawRewards) { + String[] parts = raw.split(":", 2); + Material material = Material.matchMaterial(parts[0].trim().toUpperCase()); + if (material == null || material.isAir()) { + plugin.getLogger().warning("Invalid reward material: " + raw); + continue; + } + + int amount = 1; + if (parts.length > 1) { + try { + amount = Math.max(1, Integer.parseInt(parts[1].trim())); + } catch (NumberFormatException ex) { + plugin.getLogger().warning("Invalid reward amount: " + raw); + continue; + } + } + + rewards.add(new ItemStack(material, amount)); + } + return rewards; + } + + private String color(String input) { + return ChatColor.translateAlternateColorCodes('&', input); + } +} + diff --git a/src/main/java/de/winniepat/fallSMPRewards/storage/PlayerDataStore.java b/src/main/java/de/winniepat/fallSMPRewards/storage/PlayerDataStore.java new file mode 100644 index 0000000..c4fab43 --- /dev/null +++ b/src/main/java/de/winniepat/fallSMPRewards/storage/PlayerDataStore.java @@ -0,0 +1,101 @@ +package de.winniepat.fallSMPRewards.storage; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import de.winniepat.fallSMPRewards.FallSMPRewards; +import de.winniepat.fallSMPRewards.model.PlayerData; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public final class PlayerDataStore { + + public record KillAddResult(boolean counted, int totalKills, long remainingCooldownMillis) { + } + + private final FallSMPRewards plugin; + private final Gson gson; + private final Path dataDirectory; + private final Map cache = new HashMap<>(); + + public PlayerDataStore(FallSMPRewards plugin) { + this.plugin = plugin; + this.gson = new GsonBuilder().setPrettyPrinting().create(); + this.dataDirectory = plugin.getDataFolder().toPath().resolve("playerdata"); + + try { + Files.createDirectories(dataDirectory); + } catch (IOException ex) { + throw new IllegalStateException("Could not create player data directory", ex); + } + } + + public PlayerData get(UUID uuid) { + return cache.computeIfAbsent(uuid, this::load); + } + + public KillAddResult addKillWithCooldown(UUID killerUuid, UUID victimUuid, long cooldownMillis) { + PlayerData data = get(killerUuid); + long nowMillis = System.currentTimeMillis(); + long remainingCooldownMillis = data.getRemainingCooldownMillis(victimUuid, cooldownMillis, nowMillis); + if (remainingCooldownMillis > 0L) { + return new KillAddResult(false, data.getTotalKills(), remainingCooldownMillis); + } + + data.setLastCountedKill(victimUuid, nowMillis); + int totalKills = data.addKill(); + save(killerUuid, data); + return new KillAddResult(true, totalKills, 0L); + } + + public boolean claim(UUID uuid, String questId) { + PlayerData data = get(uuid); + boolean claimed = data.claim(questId); + if (claimed) { + save(uuid, data); + } + return claimed; + } + + public void flush() { + for (Map.Entry entry : cache.entrySet()) { + save(entry.getKey(), entry.getValue()); + } + } + + private PlayerData load(UUID uuid) { + Path file = getPath(uuid); + if (!Files.exists(file)) { + return new PlayerData(uuid); + } + + try { + String json = Files.readString(file); + PlayerData data = gson.fromJson(json, PlayerData.class); + if (data == null) { + return new PlayerData(uuid); + } + data.sanitize(uuid); + return data; + } catch (IOException ex) { + plugin.getLogger().warning("Failed to read player data for " + uuid + ": " + ex.getMessage()); + return new PlayerData(uuid); + } + } + + private void save(UUID uuid, PlayerData data) { + try { + Files.writeString(getPath(uuid), gson.toJson(data)); + } catch (IOException ex) { + plugin.getLogger().warning("Failed to save player data for " + uuid + ": " + ex.getMessage()); + } + } + + private Path getPath(UUID uuid) { + return dataDirectory.resolve(uuid + ".json"); + } +} + diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..46e3cd9 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,39 @@ +kill-count-cooldown-minutes: 10 + +gui: + title: "&6&lKill Rewards" + +sounds: + open: BLOCK_CHEST_OPEN + progress: ENTITY_EXPERIENCE_ORB_PICKUP + unlocked: UI_TOAST_CHALLENGE_COMPLETE + claim: ENTITY_PLAYER_LEVELUP + denied: BLOCK_NOTE_BLOCK_BASS + +quests: + hunter_1: + required-kills: 5 + title: "&aHunter I" + description: + - "&7Kill 5 players in total." + - "&7Then claim your first reward." + rewards: + - "NETHERITE_INGOT:1" + + hunter_2: + required-kills: 15 + title: "&bHunter II" + description: + - "&7Keep going and reach 15 kills." + - "&7More kills, better loot." + rewards: + - "NETHERITE_SWORD:1" + + hunter_3: + required-kills: 30 + title: "&dHunter III" + description: + - "&7Reach 30 player kills." + - "&7Final reward tier." + rewards: + - "ENCHANTED_GOLDEN_APPLE:1" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..8e50abb --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,19 @@ +name: FallSMP-Rewards +version: '${version}' +main: de.winniepat.fallSMPRewards.FallSMPRewards +api-version: '1.21' +authors: [ WinniePatGG ] +website: https://winniepat.de + +commands: + rewards: + description: Opens the kill reward quests GUI or reloads config. + usage: /rewards [reload] + +permissions: + fallsmprewards.use: + description: Allows opening the rewards GUI. + default: true + fallsmprewards.reload: + description: Allows reloading the rewards config. + default: op