commit 99f0c4c9b402c773a5b4987517bb61e7624687b0 Author: darwincereska Date: Sun May 3 22:58:55 2026 -0400 first init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbfbcca --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Gradle +.gradle/ +build/ +out/ + +# Loom / Mod Dev Gradle caches +.loom-cache/ +.mache/ + +# Run directories (generated by runClient/runServer) +run/ + +# IDE — IntelliJ IDEA +.idea/ +*.iml +*.iws +*.ipr + +# IDE — Eclipse +bin/ +eclipse/ +.classpath +.project +.settings/ + +# IDE — VS Code +.vscode/ + +# macOS +.DS_Store + +# Windows +Thumbs.db + +# Local overrides +local.properties +gradle-app.setting \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..83a700f --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Soul Steal + +Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bounties, tracker compasses, and a vanilla-compatible soul shop. + +## Requirements + +| Requirement | Value | +| --- | --- | +| Minecraft | 1.21.11 | +| Loader | Fabric Loader 0.19.2 | +| Fabric API | 0.141.3+1.21.11 | +| Java | 21 | + +## How It Works + +Players gain souls for killing other players and lose souls whenever they die, with all values driven by `config.yml`. The bounty system lets players spend souls to place timed bounties that pay killers on claim or reward survivors on expiry, while wanted players can see a bounty timer bossbar. The optional HUD sidebar can be toggled per player, and `/souls top` shows the configured leaderboard. The shop is a server-side chest GUI with a category home page, arrow pagination, optional reward display names, and item listings that can open a quantity selector. + +## Commands + +| Command | Permission | Description | +| --- | --- | --- | +| `/souls` | All players | Shows your current soul balance. | +| `/souls balance ` | Admins / `soulsteal.admin` or `soulsteal.admin.balance.others` | Views another player's soul balance. | +| `/souls pay ` | All players | Transfers souls to another player if transfers are enabled. | +| `/souls shop [category]` | All players | Opens the soul shop GUI, optionally on a specific category. | +| `/souls bounty place [durationSeconds]` | All players | Places a timed bounty on another player. | +| `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. | +| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. | +| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. | +| `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. | +| `/souls set|add|take ` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | + +## Configuration + +| File | Purpose | +| --- | --- | +| `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. | +| `config/soulsteal/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. | +| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. | \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..697ded2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'fabric-loom' version "${loom_version}" + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + mavenCentral() + maven { + url = 'https://maven.fabricmc.net/' + } + maven { + url = 'https://repo.lucko.me/' + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + implementation "org.yaml:snakeyaml:${project.snakeyaml_version}" + include "org.yaml:snakeyaml:${project.snakeyaml_version}" +} + +processResources { + inputs.property 'version', project.version + + filesMatching('fabric.mod.json') { + expand(version: project.version) + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + if (file('LICENSE').exists()) { + from('LICENSE') { + rename { "${it}_${project.name}" } + } + } +} + +publishing { + publications { + create('mavenJava', MavenPublication) { + from components.java + } + } + + repositories { + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ead490b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +# Fabric Properties +minecraft_version=1.21.11 +yarn_mappings=1.21.11+build.5 +loader_version=0.19.2 +loom_version=1.16.1 +fabric_api_version=0.141.3+1.21.11 + +# Mod Properties +mod_version=0.2.0 +maven_group=com.g2806.soulsteal +archives_base_name=soul-steal + +# Library Versions +luckperms_api_version=5.4 +fabric_permissions_api_version=0.6.1 +snakeyaml_version=2.5 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 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..b52fb7e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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 + + + +# 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" ) + + 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" \ + -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 100755 index 0000000..aa5f10b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@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, and ensure extensions are enabled +setlocal EnableExtensions + +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 + +"%COMSPEC%" /c exit 1 + +: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 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..6c48b36 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + maven { + url = 'https://maven.fabricmc.net/' + } + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = 'Soul Steal' \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/SoulStealMod.java b/src/main/java/com/g2806/soulsteal/SoulStealMod.java new file mode 100644 index 0000000..9e062d9 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/SoulStealMod.java @@ -0,0 +1,196 @@ +package com.g2806.soulsteal; + +import com.g2806.soulsteal.command.SoulCommandRegistrar; +import com.g2806.soulsteal.config.ConfigBundle; +import com.g2806.soulsteal.config.SoulStealConfig; +import com.g2806.soulsteal.data.SoulStealDataStore; +import com.g2806.soulsteal.service.BountyService; +import com.g2806.soulsteal.service.HudService; +import com.g2806.soulsteal.service.PermissionService; +import com.g2806.soulsteal.service.RewardService; +import com.g2806.soulsteal.service.ShopService; +import com.g2806.soulsteal.service.SoulService; +import com.g2806.soulsteal.service.TrackerCompassService; +import com.g2806.soulsteal.util.SoulTexts; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Entrypoint for the Soul Steal mod. + * + *

The bulk of the feature wiring is added in subsequent modules, but this class remains the + * single bootstrap location for lifecycle setup and shared constants.

+ */ +public final class SoulStealMod implements ModInitializer { + public static final String MOD_ID = "soulsteal"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private Path configDirectory; + private ConfigBundle configBundle; + private SoulStealDataStore dataStore; + private SoulService soulService; + private PermissionService permissionService; + private BountyService bountyService; + private RewardService rewardService; + private TrackerCompassService trackerCompassService; + private ShopService shopService; + private HudService hudService; + + @Override + public void onInitialize() { + LOGGER.info("Initializing Soul Steal"); + configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID); + + try { + configBundle = ConfigBundle.load(configDirectory); + dataStore = new SoulStealDataStore(configDirectory); + dataStore.load(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception); + } + + permissionService = new PermissionService(dataStore); + soulService = new SoulService(this::config, dataStore); + bountyService = new BountyService(this::config, dataStore, soulService); + rewardService = new RewardService(permissionService, soulService); + trackerCompassService = new TrackerCompassService(this::config); + shopService = new ShopService(this::bundle, soulService, rewardService, dataStore); + hudService = new HudService(this::config, dataStore, soulService, bountyService); + + CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this)); + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player)); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player)); + ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> { + if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) { + onPlayerKilledOtherPlayer(killer, victim); + } + }); + ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> { + if (entity instanceof ServerPlayerEntity player) { + onPlayerDeath(player, damageSource); + } + }); + ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData()); + } + + private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) { + long reward = config().economy().killReward(); + if (reward > 0L) { + soulService.addSouls(killer.getUuid(), reward); + killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false); + } + + BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid()); + if (bountyClaim.claimedAny()) { + MinecraftServer server = killer.getCommandSource().getServer(); + server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false); + } + + trackerCompassService.giveTrackerCompass(killer, victim); + } + + private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) { + SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid()); + if (penalty.delta() < 0L) { + player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false); + } + + if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) { + java.util.List removedBounties = bountyService.clearForTarget(player.getUuid()); + if (!removedBounties.isEmpty()) { + player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false); + } + } + } + + private void onServerTick(MinecraftServer server) { + trackerCompassService.tick(server); + if (server.getTicks() % 20 != 0) { + return; + } + + long now = System.currentTimeMillis(); + for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) { + ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid()); + if (target != null && payout.reward() > 0L) { + target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false); + } + + if (payout.reward() > 0L) { + server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false); + } + } + + hudService.tick(server, now); + } + + public boolean reloadConfiguration() { + try { + configBundle = ConfigBundle.load(configDirectory); + return true; + } catch (IOException exception) { + LOGGER.error("Failed to reload Soul Steal configuration.", exception); + return false; + } + } + + private void saveData() { + try { + dataStore.save(); + } catch (IOException exception) { + LOGGER.error("Failed to save Soul Steal data.", exception); + } + } + + public ConfigBundle bundle() { + return configBundle; + } + + public SoulStealConfig config() { + return configBundle.config(); + } + + public SoulService soulService() { + return soulService; + } + + public PermissionService permissionService() { + return permissionService; + } + + public BountyService bountyService() { + return bountyService; + } + + public RewardService rewardService() { + return rewardService; + } + + public TrackerCompassService trackerCompassService() { + return trackerCompassService; + } + + public ShopService shopService() { + return shopService; + } + + public HudService hudService() { + return hudService; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java new file mode 100644 index 0000000..e40faa9 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java @@ -0,0 +1,272 @@ +package com.g2806.soulsteal.command; + +import com.g2806.soulsteal.SoulStealMod; +import com.g2806.soulsteal.data.StoredBounty; +import com.g2806.soulsteal.service.BountyService; +import com.g2806.soulsteal.service.HudService; +import com.g2806.soulsteal.service.SoulService; +import com.g2806.soulsteal.util.DurationFormatter; +import com.g2806.soulsteal.util.SoulTexts; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.LongArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import net.minecraft.command.argument.EntityArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +/** Registers the public command surface for Soul Steal. */ +public final class SoulCommandRegistrar { + private SoulCommandRegistrar() { + } + + public static void register(CommandDispatcher dispatcher, SoulStealMod mod) { + dispatcher.register(buildRoot("souls", mod)); + dispatcher.register(buildRoot("soul", mod)); + } + + private static com.mojang.brigadier.builder.LiteralArgumentBuilder buildRoot(String rootName, SoulStealMod mod) { + return literal(rootName) + .executes(context -> showOwnBalance(context, mod)) + .then(literal("balance") + .executes(context -> showOwnBalance(context, mod)) + .then(argument("player", EntityArgumentType.player()) + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().balanceOthersNode())) + .executes(context -> showTargetBalance(context, mod)))) + .then(literal("pay") + .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", LongArgumentType.longArg(1L)) + .executes(context -> transferSouls(context, mod))))) + .then(literal("shop") + .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) + .executes(context -> openShop(context, mod, null)) + .then(argument("category", StringArgumentType.word()) + .executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category"))))) + .then(literal("bounty") + .requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0)) + .then(literal("place") + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", LongArgumentType.longArg(1L)) + .executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds())) + .then(argument("durationSeconds", LongArgumentType.longArg(1L)) + .executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds"))))))) + .then(literal("list") + .executes(context -> listBounties(context, mod, null)) + .then(argument("player", EntityArgumentType.player()) + .executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player")))))) + .then(literal("scoreboard") + .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode())) + .executes(context -> showScoreboardStatus(context, mod)) + .then(literal("toggle") + .executes(context -> toggleScoreboard(context, mod))) + .then(literal("on") + .executes(context -> setScoreboardVisibility(context, mod, true))) + .then(literal("off") + .executes(context -> setScoreboardVisibility(context, mod, false)))) + .then(literal("top") + .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode())) + .executes(context -> showLeaderboard(context, mod, 1)) + .then(argument("page", IntegerArgumentType.integer(1)) + .executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page"))))) + .then(literal("reload") + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().reloadNode())) + .executes(context -> reload(context, mod))) + .then(literal("set") + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().setNode())) + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", LongArgumentType.longArg(0L)) + .executes(context -> setBalance(context, mod))))) + .then(literal("add") + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().addNode())) + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", LongArgumentType.longArg(1L)) + .executes(context -> addBalance(context, mod))))) + .then(literal("take") + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().takeNode())) + .then(argument("player", EntityArgumentType.player()) + .then(argument("amount", LongArgumentType.longArg(1L)) + .executes(context -> takeBalance(context, mod))))); + } + + private static int showOwnBalance(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + context.getSource().sendFeedback(() -> SoulTexts.info("Your balance is " + mod.soulService().balanceOf(player.getUuid()) + " souls."), false); + return 1; + } + + private static int showTargetBalance(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + context.getSource().sendFeedback(() -> SoulTexts.info(target.getName().getString() + " has " + mod.soulService().balanceOf(target.getUuid()) + " souls."), false); + return 1; + } + + private static int transferSouls(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity sender = context.getSource().getPlayerOrThrow(); + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); + + if (sender.getUuid().equals(target.getUuid())) { + context.getSource().sendError(SoulTexts.error("You cannot transfer souls to yourself.")); + return 0; + } + + SoulService.TransferResult result = mod.soulService().transfer(sender.getUuid(), target.getUuid(), amount); + if (!result.success()) { + context.getSource().sendError(SoulTexts.error(result.message())); + return 0; + } + + sender.sendMessage(SoulTexts.success("Transferred " + amount + " souls to " + target.getName().getString() + "."), false); + target.sendMessage(SoulTexts.info(sender.getName().getString() + " sent you " + amount + " souls."), false); + return 1; + } + + private static int openShop(CommandContext context, SoulStealMod mod, String categoryKey) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + mod.shopService().openShop(player, categoryKey, 0); + return 1; + } + + private static int showScoreboardStatus(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + boolean visible = mod.hudService().isScoreboardVisible(player.getUuid()); + String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled."; + if (!mod.config().hud().scoreboard().enabled()) { + message += " The server-wide HUD toggle is disabled in config."; + } + String finalMessage = message; + context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false); + return 1; + } + + private static int toggleScoreboard(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + if (!mod.config().hud().scoreboard().enabled()) { + context.getSource().sendError(SoulTexts.error("The scoreboard HUD is disabled in config.")); + return 0; + } + + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + boolean visible = mod.hudService().toggleScoreboardVisible(player); + context.getSource().sendFeedback(() -> SoulTexts.success("Scoreboard HUD " + (visible ? "enabled." : "disabled.")), false); + return 1; + } + + private static int setScoreboardVisibility(CommandContext context, SoulStealMod mod, boolean visible) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + if (!mod.config().hud().scoreboard().enabled()) { + context.getSource().sendError(SoulTexts.error("The scoreboard HUD is disabled in config.")); + return 0; + } + + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + mod.hudService().setScoreboardVisible(player, visible); + context.getSource().sendFeedback(() -> SoulTexts.success("Scoreboard HUD " + (visible ? "enabled." : "disabled.")), false); + return 1; + } + + private static int showLeaderboard(CommandContext context, SoulStealMod mod, int page) { + HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page); + if (leaderboardPage.entries().isEmpty()) { + context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false); + return 1; + } + + int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize()); + context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false); + for (int index = 0; index < leaderboardPage.entries().size(); index++) { + HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index); + int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1; + context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false); + } + return 1; + } + + private static int placeBounty(CommandContext context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); + + BountyService.PlaceBountyResult result = mod.bountyService().placeBounty( + placer.getUuid(), + placer.getName().getString(), + target.getUuid(), + target.getName().getString(), + amount, + durationSeconds, + System.currentTimeMillis() + ); + + if (!result.success()) { + context.getSource().sendError(SoulTexts.error(result.message())); + return 0; + } + + Text broadcast = SoulTexts.info(placer.getName().getString() + " placed a " + amount + " soul bounty on " + target.getName().getString() + " for " + DurationFormatter.formatSeconds(durationSeconds) + "."); + context.getSource().getServer().getPlayerManager().broadcast(broadcast, false); + return 1; + } + + private static int listBounties(CommandContext context, SoulStealMod mod, ServerPlayerEntity target) { + java.util.List bounties = target == null ? mod.bountyService().activeBounties() : mod.bountyService().activeBountiesForTarget(target.getUuid()); + if (bounties.isEmpty()) { + context.getSource().sendFeedback(() -> SoulTexts.info("There are no matching active bounties."), false); + return 1; + } + + context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false); + for (StoredBounty bounty : bounties) { + long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L); + context.getSource().sendFeedback(() -> Text.literal("- " + bounty.targetName() + " | " + bounty.soulValue() + " souls | by " + bounty.placerName() + " | expires in " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(net.minecraft.util.Formatting.GRAY), false); + } + return 1; + } + + private static int reload(CommandContext context, SoulStealMod mod) { + if (!mod.reloadConfiguration()) { + context.getSource().sendError(SoulTexts.error("Failed to reload Soul Steal configuration. Check the server log for details.")); + return 0; + } + + context.getSource().sendFeedback(() -> SoulTexts.success("Reloaded Soul Steal configuration."), true); + return 1; + } + + private static int setBalance(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); + mod.soulService().setSouls(target.getUuid(), amount); + context.getSource().sendFeedback(() -> SoulTexts.success("Set " + target.getName().getString() + " to " + amount + " souls."), true); + return 1; + } + + private static int addBalance(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); + long balance = mod.soulService().addSouls(target.getUuid(), amount); + context.getSource().sendFeedback(() -> SoulTexts.success("Added " + amount + " souls to " + target.getName().getString() + ". New balance: " + balance), true); + return 1; + } + + private static int takeBalance(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); + long balance = mod.soulService().removeSouls(target.getUuid(), amount); + context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true); + return 1; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java new file mode 100644 index 0000000..f403cb9 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java @@ -0,0 +1,22 @@ +package com.g2806.soulsteal.config; + +import com.g2806.soulsteal.shop.ShopCatalog; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** Loads and groups the editable YAML files used by the mod. */ +public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog) { + public static ConfigBundle load(Path configDirectory) throws IOException { + Files.createDirectories(configDirectory); + + Map configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml()); + SoulStealConfig config = SoulStealConfig.fromMap(configMap); + + Map shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); + ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); + + return new ConfigBundle(config, shopCatalog); + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java new file mode 100644 index 0000000..5658d43 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java @@ -0,0 +1,257 @@ +package com.g2806.soulsteal.config; + +import java.util.Map; + +/** + * Main configuration tree for the mod's economy, bounty, tracker, and permission settings. + */ +public record SoulStealConfig( + EconomyConfig economy, + BountyConfig bounty, + TrackerConfig tracker, + ShopUiConfig shop, + HudConfig hud, + PermissionConfig permissions +) { + public static SoulStealConfig fromMap(Map root) { + Map economySection = YamlConfigHelper.section(root, "economy"); + Map deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty"); + Map transferSection = YamlConfigHelper.section(economySection, "transfer"); + Map bountySection = YamlConfigHelper.section(root, "bounties"); + Map trackerSection = YamlConfigHelper.section(root, "tracker"); + Map shopSection = YamlConfigHelper.section(root, "shop"); + Map hudSection = YamlConfigHelper.section(root, "hud"); + Map scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard"); + Map bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar"); + Map leaderboardSection = YamlConfigHelper.section(hudSection, "leaderboard"); + Map permissionsSection = YamlConfigHelper.section(root, "permissions"); + + EconomyConfig economyConfig = new EconomyConfig( + Math.max(0L, YamlConfigHelper.longValue(economySection, "starting_souls", 0L)), + Math.max(1L, YamlConfigHelper.longValue(economySection, "max_souls", 1_000_000L)), + Math.max(0L, YamlConfigHelper.longValue(economySection, "kill_reward", 25L)), + new DeathPenaltyConfig( + Math.max(0L, YamlConfigHelper.longValue(deathPenaltySection, "flat", 15L)), + clampPercent(YamlConfigHelper.doubleValue(deathPenaltySection, "percent", 0.10D)), + Math.max(0L, YamlConfigHelper.longValue(deathPenaltySection, "minimum", 5L)), + Math.max(0L, YamlConfigHelper.longValue(deathPenaltySection, "maximum", 100L)) + ), + new TransferConfig( + YamlConfigHelper.bool(transferSection, "enabled", true), + Math.max(1L, YamlConfigHelper.longValue(transferSection, "minimum", 1L)) + ) + ); + + BountyConfig bountyConfig = new BountyConfig( + YamlConfigHelper.bool(bountySection, "enabled", true), + Math.max(1L, YamlConfigHelper.longValue(bountySection, "min_value", 25L)), + Math.max(1L, YamlConfigHelper.longValue(bountySection, "max_value", 10_000L)), + Math.max(60L, YamlConfigHelper.longValue(bountySection, "default_duration_seconds", 7_200L)), + Math.max(30L, YamlConfigHelper.longValue(bountySection, "min_duration_seconds", 600L)), + Math.max(60L, YamlConfigHelper.longValue(bountySection, "max_duration_seconds", 86_400L)), + clampPercent(YamlConfigHelper.doubleValue(bountySection, "survivor_reward_percent", 0.50D)), + Math.max(0L, YamlConfigHelper.longValue(bountySection, "placement_cooldown_seconds", 60L)), + Math.max(1, YamlConfigHelper.intValue(bountySection, "max_active_per_target", 5)), + Math.max(1, YamlConfigHelper.intValue(bountySection, "max_active_per_placer", 3)) + ); + + if (bountyConfig.maxValue() < bountyConfig.minValue()) { + bountyConfig = bountyConfig.withMaxValue(bountyConfig.minValue()); + } + if (bountyConfig.maxDurationSeconds() < bountyConfig.minDurationSeconds()) { + bountyConfig = bountyConfig.withMaxDurationSeconds(bountyConfig.minDurationSeconds()); + } + + TrackerConfig trackerConfig = new TrackerConfig( + YamlConfigHelper.bool(trackerSection, "enabled", true), + Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)), + Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)), + YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false) + ); + + ShopUiConfig shopUiConfig = new ShopUiConfig( + YamlConfigHelper.string(shopSection, "title", "Soul Shop"), + clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)), + YamlConfigHelper.string(shopSection, "filler_item", "minecraft:black_stained_glass_pane"), + Math.max(0L, YamlConfigHelper.longValue(shopSection, "default_purchase_cooldown_seconds", 0L)), + YamlConfigHelper.bool(shopSection, "enable_custom_amount_selector", true), + Math.max(1, YamlConfigHelper.intValue(shopSection, "default_max_custom_amount", 64)) + ); + + HudConfig hudConfig = new HudConfig( + new ScoreboardConfig( + YamlConfigHelper.bool(scoreboardSection, "enabled", true), + YamlConfigHelper.bool(scoreboardSection, "default_visible", false), + YamlConfigHelper.string(scoreboardSection, "title", "Soul HUD") + ), + new BountyBossbarConfig( + YamlConfigHelper.bool(bossbarSection, "enabled", true), + YamlConfigHelper.string(bossbarSection, "title", "Bounty on You") + ), + new LeaderboardConfig( + Math.max(1, YamlConfigHelper.intValue(leaderboardSection, "page_size", 10)) + ) + ); + + PermissionConfig permissionConfig = new PermissionConfig( + YamlConfigHelper.string(permissionsSection, "admin_node", "soulsteal.admin"), + YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"), + YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"), + YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"), + YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"), + YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"), + YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"), + YamlConfigHelper.string(permissionsSection, "take_node", "soulsteal.admin.balance.take"), + YamlConfigHelper.string(permissionsSection, "scoreboard_node", "soulsteal.scoreboard"), + YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard") + ); + + return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, shopUiConfig, hudConfig, permissionConfig); + } + + public static String defaultYaml() { + return """ + economy: + starting_souls: 0 + max_souls: 1000000 + kill_reward: 25 + death_penalty: + flat: 15 + percent: 0.10 + minimum: 5 + maximum: 100 + transfer: + enabled: true + minimum: 1 + + bounties: + enabled: true + min_value: 25 + max_value: 10000 + default_duration_seconds: 7200 + min_duration_seconds: 600 + max_duration_seconds: 86400 + survivor_reward_percent: 0.50 + placement_cooldown_seconds: 60 + max_active_per_target: 5 + max_active_per_placer: 3 + + tracker: + enabled: true + duration_seconds: 900 + update_interval_ticks: 20 + expire_if_target_offline: false + + shop: + title: "Soul Shop" + rows: 3 + filler_item: "minecraft:black_stained_glass_pane" + default_purchase_cooldown_seconds: 0 + enable_custom_amount_selector: true + default_max_custom_amount: 64 + + hud: + scoreboard: + enabled: true + default_visible: false + title: "Soul HUD" + bounty_bossbar: + enabled: true + title: "Bounty on You" + leaderboard: + page_size: 10 + + permissions: + # soulsteal.admin grants every admin-only action below. + admin_node: "soulsteal.admin" + reload_node: "soulsteal.admin.reload" + shop_node: "soulsteal.shop" + bounty_node: "soulsteal.bounty" + balance_others_node: "soulsteal.admin.balance.others" + set_node: "soulsteal.admin.balance.set" + add_node: "soulsteal.admin.balance.add" + take_node: "soulsteal.admin.balance.take" + scoreboard_node: "soulsteal.scoreboard" + leaderboard_node: "soulsteal.leaderboard" + """; + } + + private static double clampPercent(double value) { + return Math.max(0.0D, Math.min(1.0D, value)); + } + + private static int clampRows(int rows) { + return Math.max(2, Math.min(6, rows)); + } + + public record EconomyConfig(long startingSouls, long maxSouls, long killReward, DeathPenaltyConfig deathPenalty, TransferConfig transfer) { + } + + public record DeathPenaltyConfig(long flat, double percent, long minimum, long maximum) { + } + + public record TransferConfig(boolean enabled, long minimum) { + } + + public record BountyConfig( + boolean enabled, + long minValue, + long maxValue, + long defaultDurationSeconds, + long minDurationSeconds, + long maxDurationSeconds, + double survivorRewardPercent, + long placementCooldownSeconds, + int maxActivePerTarget, + int maxActivePerPlacer + ) { + public BountyConfig withMaxValue(long value) { + return new BountyConfig(enabled, minValue, value, defaultDurationSeconds, minDurationSeconds, maxDurationSeconds, + survivorRewardPercent, placementCooldownSeconds, maxActivePerTarget, maxActivePerPlacer); + } + + public BountyConfig withMaxDurationSeconds(long value) { + return new BountyConfig(enabled, minValue, maxValue, defaultDurationSeconds, minDurationSeconds, value, + survivorRewardPercent, placementCooldownSeconds, maxActivePerTarget, maxActivePerPlacer); + } + } + + public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) { + } + + public record ShopUiConfig( + String title, + int rows, + String fillerItemId, + long defaultPurchaseCooldownSeconds, + boolean customAmountSelectorEnabled, + int defaultMaxCustomAmount + ) { + } + + public record HudConfig(ScoreboardConfig scoreboard, BountyBossbarConfig bountyBossbar, LeaderboardConfig leaderboard) { + } + + public record ScoreboardConfig(boolean enabled, boolean defaultVisible, String title) { + } + + public record BountyBossbarConfig(boolean enabled, String title) { + } + + public record LeaderboardConfig(int pageSize) { + } + + public record PermissionConfig( + String adminNode, + String reloadNode, + String shopNode, + String bountyNode, + String balanceOthersNode, + String setNode, + String addNode, + String takeNode, + String scoreboardNode, + String leaderboardNode + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/config/YamlConfigHelper.java b/src/main/java/com/g2806/soulsteal/config/YamlConfigHelper.java new file mode 100644 index 0000000..3d21ec3 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/config/YamlConfigHelper.java @@ -0,0 +1,152 @@ +package com.g2806.soulsteal.config; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.yaml.snakeyaml.Yaml; + +/** + * Utility methods for loading and navigating YAML-backed configuration data. + * + *

The mod keeps configuration editable by server owners, so parsing stays permissive and falls + * back to safe defaults instead of aborting on every missing field.

+ */ +public final class YamlConfigHelper { + private static final Yaml YAML = new Yaml(); + + private YamlConfigHelper() { + } + + /** + * Loads a YAML file, creating it from the supplied default contents when it does not already + * exist. + */ + public static Map loadOrCreate(Path path, String defaultContents) throws IOException { + Objects.requireNonNull(path, "path"); + if (Files.notExists(path)) { + Files.createDirectories(path.getParent()); + Files.writeString(path, defaultContents, StandardCharsets.UTF_8); + } + + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + Object loaded = YAML.load(reader); + if (loaded instanceof Map rawMap) { + return map(rawMap); + } + } + + return new LinkedHashMap<>(); + } + + /** Returns a nested configuration section as a mutable string-keyed map. */ + public static Map section(Map root, String key) { + Object value = root.get(key); + if (value instanceof Map rawMap) { + return map(rawMap); + } + return new LinkedHashMap<>(); + } + + /** Returns a list from the current section, or an empty list when the key is not present. */ + public static List list(Map root, String key) { + Object value = root.get(key); + if (value instanceof List rawList) { + return new ArrayList<>(rawList); + } + return List.of(); + } + + public static String string(Map root, String key, String defaultValue) { + Object value = root.get(key); + return value == null ? defaultValue : String.valueOf(value); + } + + public static boolean bool(Map root, String key, boolean defaultValue) { + Object value = root.get(key); + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + if (value instanceof String stringValue) { + return Boolean.parseBoolean(stringValue); + } + return defaultValue; + } + + public static int intValue(Map root, String key, int defaultValue) { + Object value = root.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String stringValue) { + try { + return Integer.parseInt(stringValue); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + public static long longValue(Map root, String key, long defaultValue) { + Object value = root.get(key); + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof String stringValue) { + try { + return Long.parseLong(stringValue); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + public static double doubleValue(Map root, String key, double defaultValue) { + Object value = root.get(key); + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String stringValue) { + try { + return Double.parseDouble(stringValue); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + public static List stringList(Map root, String key) { + List rawList = list(root, key); + List result = new ArrayList<>(rawList.size()); + for (Object entry : rawList) { + if (entry != null) { + result.add(String.valueOf(entry)); + } + } + return result; + } + + public static String requiredType(Map root, String key, String defaultType) { + return string(root, key, defaultType).trim().toUpperCase(Locale.ROOT); + } + + private static Map map(Map rawMap) { + Map converted = new LinkedHashMap<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getKey() != null) { + converted.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return converted; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java new file mode 100644 index 0000000..c6a477c --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java @@ -0,0 +1,85 @@ +package com.g2806.soulsteal.data; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Mutable persisted state for the mod. + * + *

The structure intentionally stays JSON-friendly so server owners can inspect or recover the + * file by hand if needed.

+ */ +public final class SoulStealData { + private Map souls = new HashMap<>(); + private List activeBounties = new ArrayList<>(); + private Map> unlockedEntries = new HashMap<>(); + private Map> purchaseCooldowns = new HashMap<>(); + private Map> grantedPermissions = new HashMap<>(); + private Map bountyPlacementCooldowns = new HashMap<>(); + private Map playerNames = new HashMap<>(); + private Map scoreboardVisibility = new HashMap<>(); + + public SoulStealData normalize() { + if (souls == null) { + souls = new HashMap<>(); + } + if (activeBounties == null) { + activeBounties = new ArrayList<>(); + } + if (unlockedEntries == null) { + unlockedEntries = new HashMap<>(); + } + if (purchaseCooldowns == null) { + purchaseCooldowns = new HashMap<>(); + } + if (grantedPermissions == null) { + grantedPermissions = new HashMap<>(); + } + if (bountyPlacementCooldowns == null) { + bountyPlacementCooldowns = new HashMap<>(); + } + if (playerNames == null) { + playerNames = new HashMap<>(); + } + if (scoreboardVisibility == null) { + scoreboardVisibility = new HashMap<>(); + } + return this; + } + + public Map souls() { + return souls; + } + + public List activeBounties() { + return activeBounties; + } + + public Map> unlockedEntries() { + return unlockedEntries; + } + + public Map> purchaseCooldowns() { + return purchaseCooldowns; + } + + public Map> grantedPermissions() { + return grantedPermissions; + } + + public Map bountyPlacementCooldowns() { + return bountyPlacementCooldowns; + } + + public Map playerNames() { + return playerNames; + } + + public Map scoreboardVisibility() { + return scoreboardVisibility; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java b/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java new file mode 100644 index 0000000..23251d9 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java @@ -0,0 +1,63 @@ +package com.g2806.soulsteal.data; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Reads and writes the persistent soul economy data file. + * + *

All write operations use a temp file and replace step to avoid leaving partial JSON behind on + * crash or forced shutdown.

+ */ +public final class SoulStealDataStore { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + + private final Path dataDirectory; + private final Path dataFile; + private SoulStealData data = new SoulStealData(); + + public SoulStealDataStore(Path dataDirectory) { + this.dataDirectory = dataDirectory; + this.dataFile = dataDirectory.resolve("soulsteal-data.json"); + } + + public synchronized void load() throws IOException { + Files.createDirectories(dataDirectory); + if (Files.notExists(dataFile)) { + data = new SoulStealData(); + save(); + return; + } + + try (Reader reader = Files.newBufferedReader(dataFile, StandardCharsets.UTF_8)) { + SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class); + data = loaded == null ? new SoulStealData() : loaded.normalize(); + } + } + + public synchronized SoulStealData data() { + return data; + } + + public synchronized void save() throws IOException { + Files.createDirectories(dataDirectory); + Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp"); + try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { + GSON.toJson(data, writer); + } + + try { + Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/data/StoredBounty.java b/src/main/java/com/g2806/soulsteal/data/StoredBounty.java new file mode 100644 index 0000000..4ba3be8 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/data/StoredBounty.java @@ -0,0 +1,32 @@ +package com.g2806.soulsteal.data; + +import java.util.UUID; + +/** + * Persistent bounty entry stored on disk. + * + *

Names are duplicated alongside UUIDs so chat messages stay readable even when one of the + * players is offline during resolution.

+ */ +public record StoredBounty( + String id, + String placerUuid, + String placerName, + String targetUuid, + String targetName, + long soulValue, + long createdAtEpochMillis, + long expiresAtEpochMillis +) { + public UUID idAsUuid() { + return UUID.fromString(id); + } + + public UUID placerUuidAsUuid() { + return UUID.fromString(placerUuid); + } + + public UUID targetUuidAsUuid() { + return UUID.fromString(targetUuid); + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/BountyService.java b/src/main/java/com/g2806/soulsteal/service/BountyService.java new file mode 100644 index 0000000..ab1929b --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/BountyService.java @@ -0,0 +1,196 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.config.SoulStealConfig; +import com.g2806.soulsteal.data.SoulStealData; +import com.g2806.soulsteal.data.SoulStealDataStore; +import com.g2806.soulsteal.data.StoredBounty; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +/** Handles placement, expiry, and resolution of player bounties. */ +public final class BountyService { + private final Supplier configSupplier; + private final SoulStealDataStore dataStore; + private final SoulService soulService; + + public BountyService(Supplier configSupplier, SoulStealDataStore dataStore, SoulService soulService) { + this.configSupplier = configSupplier; + this.dataStore = dataStore; + this.soulService = soulService; + } + + public PlaceBountyResult placeBounty( + UUID placerUuid, + String placerName, + UUID targetUuid, + String targetName, + long amount, + long durationSeconds, + long nowEpochMillis + ) { + SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty(); + if (!bountyConfig.enabled()) { + return new PlaceBountyResult(false, "The bounty system is disabled.", null); + } + if (placerUuid.equals(targetUuid)) { + return new PlaceBountyResult(false, "You cannot place a bounty on yourself.", null); + } + if (amount < bountyConfig.minValue() || amount > bountyConfig.maxValue()) { + return new PlaceBountyResult(false, "The bounty amount is outside the configured range.", null); + } + if (durationSeconds < bountyConfig.minDurationSeconds() || durationSeconds > bountyConfig.maxDurationSeconds()) { + return new PlaceBountyResult(false, "The bounty duration is outside the configured range.", null); + } + + String placerKey = key(placerUuid); + SoulStealData data = dataStore.data(); + long cooldownUntil = data.bountyPlacementCooldowns().getOrDefault(placerKey, 0L); + if (cooldownUntil > nowEpochMillis) { + long secondsRemaining = Math.max(1L, (cooldownUntil - nowEpochMillis + 999L) / 1000L); + return new PlaceBountyResult(false, "You must wait " + secondsRemaining + " more seconds before placing another bounty.", null); + } + + long placedByPlayer = data.activeBounties().stream().filter(bounty -> bounty.placerUuid().equals(placerKey)).count(); + if (placedByPlayer >= bountyConfig.maxActivePerPlacer()) { + return new PlaceBountyResult(false, "You already have the maximum number of active bounties.", null); + } + + long activeAgainstTarget = data.activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(key(targetUuid))).count(); + if (activeAgainstTarget >= bountyConfig.maxActivePerTarget()) { + return new PlaceBountyResult(false, "That player already has the maximum number of active bounties.", null); + } + + if (!soulService.hasSouls(placerUuid, amount)) { + return new PlaceBountyResult(false, "You do not have enough souls to fund that bounty.", null); + } + + soulService.removeSouls(placerUuid, amount); + StoredBounty bounty = new StoredBounty( + UUID.randomUUID().toString(), + placerKey, + placerName, + key(targetUuid), + targetName, + amount, + nowEpochMillis, + nowEpochMillis + (durationSeconds * 1000L) + ); + data.activeBounties().add(bounty); + data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L)); + saveQuietly(); + return new PlaceBountyResult(true, "Bounty placed successfully.", bounty); + } + + public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) { + List claimed = new ArrayList<>(); + Iterator iterator = dataStore.data().activeBounties().iterator(); + long reward = 0L; + String targetKey = key(targetUuid); + + while (iterator.hasNext()) { + StoredBounty bounty = iterator.next(); + if (!bounty.targetUuid().equals(targetKey)) { + continue; + } + claimed.add(bounty); + reward += bounty.soulValue(); + iterator.remove(); + } + + if (reward > 0L) { + soulService.addSouls(killerUuid, reward); + saveQuietly(); + } + + return new ClaimBountyResult(reward, claimed); + } + + public List clearForTarget(UUID targetUuid) { + List removed = new ArrayList<>(); + Iterator iterator = dataStore.data().activeBounties().iterator(); + String targetKey = key(targetUuid); + + while (iterator.hasNext()) { + StoredBounty bounty = iterator.next(); + if (!bounty.targetUuid().equals(targetKey)) { + continue; + } + + removed.add(bounty); + iterator.remove(); + } + + if (!removed.isEmpty()) { + saveQuietly(); + } + + return removed; + } + + public List processExpirations(long nowEpochMillis) { + SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty(); + List payouts = new ArrayList<>(); + Iterator iterator = dataStore.data().activeBounties().iterator(); + + while (iterator.hasNext()) { + StoredBounty bounty = iterator.next(); + if (bounty.expiresAtEpochMillis() > nowEpochMillis) { + continue; + } + + long payout = Math.max(0L, Math.round(bounty.soulValue() * bountyConfig.survivorRewardPercent())); + if (payout > 0L) { + soulService.addSouls(bounty.targetUuidAsUuid(), payout); + } + payouts.add(new ExpiredBountyPayout(bounty, payout)); + iterator.remove(); + } + + if (!payouts.isEmpty()) { + saveQuietly(); + } + return payouts; + } + + public List activeBounties() { + return List.copyOf(dataStore.data().activeBounties()); + } + + public List activeBountiesForTarget(UUID targetUuid) { + String targetKey = key(targetUuid); + return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList(); + } + + public long nextPlacementTime(UUID placerUuid) { + return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L); + } + + private void saveQuietly() { + try { + dataStore.save(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to persist Soul Steal data.", exception); + } + } + + private static String key(UUID playerUuid) { + return playerUuid.toString(); + } + + public record PlaceBountyResult(boolean success, String message, StoredBounty bounty) { + } + + public record ClaimBountyResult(long reward, List claimedBounties) { + public boolean claimedAny() { + return reward > 0L && !claimedBounties.isEmpty(); + } + } + + public record ExpiredBountyPayout(StoredBounty bounty, long reward) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/HudService.java b/src/main/java/com/g2806/soulsteal/service/HudService.java new file mode 100644 index 0000000..4e73ec4 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/HudService.java @@ -0,0 +1,286 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.config.SoulStealConfig; +import com.g2806.soulsteal.data.SoulStealDataStore; +import com.g2806.soulsteal.data.StoredBounty; +import com.g2806.soulsteal.util.DurationFormatter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.entity.boss.BossBar; +import net.minecraft.entity.boss.ServerBossBar; +import net.minecraft.network.packet.s2c.play.ScoreboardDisplayS2CPacket; +import net.minecraft.network.packet.s2c.play.ScoreboardObjectiveUpdateS2CPacket; +import net.minecraft.network.packet.s2c.play.ScoreboardScoreResetS2CPacket; +import net.minecraft.network.packet.s2c.play.ScoreboardScoreUpdateS2CPacket; +import net.minecraft.scoreboard.Scoreboard; +import net.minecraft.scoreboard.ScoreboardCriterion; +import net.minecraft.scoreboard.ScoreboardDisplaySlot; +import net.minecraft.scoreboard.ScoreboardObjective; +import net.minecraft.scoreboard.number.BlankNumberFormat; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +/** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */ +public final class HudService { + private final Supplier configSupplier; + private final SoulStealDataStore dataStore; + private final SoulService soulService; + private final BountyService bountyService; + private final Map sidebars = new HashMap<>(); + private final Map bountyBossBars = new HashMap<>(); + + public HudService( + Supplier configSupplier, + SoulStealDataStore dataStore, + SoulService soulService, + BountyService bountyService + ) { + this.configSupplier = configSupplier; + this.dataStore = dataStore; + this.soulService = soulService; + this.bountyService = bountyService; + } + + public void handlePlayerJoin(ServerPlayerEntity player) { + rememberPlayer(player); + refreshPlayerDisplays(player, System.currentTimeMillis()); + } + + public void handlePlayerDisconnect(ServerPlayerEntity player) { + clearSidebar(player); + clearBossBar(player); + } + + public void tick(MinecraftServer server, long nowEpochMillis) { + Set onlinePlayers = new HashSet<>(); + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + onlinePlayers.add(player.getUuid()); + rememberPlayer(player); + refreshPlayerDisplays(player, nowEpochMillis); + } + + sidebars.keySet().removeIf(uuid -> !onlinePlayers.contains(uuid)); + bountyBossBars.entrySet().removeIf(entry -> { + if (onlinePlayers.contains(entry.getKey())) { + return false; + } + entry.getValue().clearPlayers(); + return true; + }); + } + + public boolean isScoreboardVisible(UUID playerUuid) { + return dataStore.data().scoreboardVisibility() + .getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible()); + } + + public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) { + rememberPlayer(player); + Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible); + if (!Objects.equals(previous, visible)) { + saveQuietly(); + } + refreshSidebar(player, System.currentTimeMillis()); + return visible; + } + + public boolean toggleScoreboardVisible(ServerPlayerEntity player) { + return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid())); + } + + public LeaderboardPage leaderboard(int requestedPage) { + Set playerKeys = new HashSet<>(dataStore.data().playerNames().keySet()); + playerKeys.addAll(dataStore.data().souls().keySet()); + + List allEntries = playerKeys.stream() + .map(playerKey -> { + UUID uuid = UUID.fromString(playerKey); + return new LeaderboardEntry(displayName(uuid), soulService.balanceOf(uuid)); + }) + .sorted(Comparator.comparingLong(LeaderboardEntry::souls).reversed().thenComparing(LeaderboardEntry::playerName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + + if (allEntries.isEmpty()) { + return new LeaderboardPage(1, 1, List.of()); + } + + int pageSize = Math.max(1, configSupplier.get().hud().leaderboard().pageSize()); + int totalPages = Math.max(1, (int) Math.ceil(allEntries.size() / (double) pageSize)); + int page = Math.max(1, Math.min(totalPages, requestedPage)); + int fromIndex = (page - 1) * pageSize; + int toIndex = Math.min(allEntries.size(), fromIndex + pageSize); + return new LeaderboardPage(page, totalPages, new ArrayList<>(allEntries.subList(fromIndex, toIndex))); + } + + private void refreshPlayerDisplays(ServerPlayerEntity player, long nowEpochMillis) { + refreshSidebar(player, nowEpochMillis); + refreshBossBar(player, nowEpochMillis); + } + + private void refreshSidebar(ServerPlayerEntity player, long nowEpochMillis) { + if (!configSupplier.get().hud().scoreboard().enabled() || !isScoreboardVisible(player.getUuid())) { + clearSidebar(player); + return; + } + + SidebarState state = sidebars.computeIfAbsent(player.getUuid(), uuid -> { + String objectiveName = objectiveName(uuid); + ScoreboardObjective objective = createObjective(objectiveName); + player.networkHandler.sendPacket(new ScoreboardObjectiveUpdateS2CPacket(objective, ScoreboardObjectiveUpdateS2CPacket.ADD_MODE)); + player.networkHandler.sendPacket(new ScoreboardDisplayS2CPacket(ScoreboardDisplaySlot.SIDEBAR, objective)); + return new SidebarState(objectiveName, List.of()); + }); + + ScoreboardObjective objective = createObjective(state.objectiveName()); + player.networkHandler.sendPacket(new ScoreboardObjectiveUpdateS2CPacket(objective, ScoreboardObjectiveUpdateS2CPacket.UPDATE_MODE)); + + for (String holder : state.holders()) { + player.networkHandler.sendPacket(new ScoreboardScoreResetS2CPacket(holder, state.objectiveName())); + } + + List lines = buildSidebarLines(player, nowEpochMillis); + List holders = new ArrayList<>(lines.size()); + for (int index = 0; index < lines.size(); index++) { + String holder = "ssl" + index; + holders.add(holder); + int score = lines.size() - index; + player.networkHandler.sendPacket(new ScoreboardScoreUpdateS2CPacket(holder, state.objectiveName(), score, Optional.of(lines.get(index)), Optional.empty())); + } + + sidebars.put(player.getUuid(), new SidebarState(state.objectiveName(), holders)); + } + + private void refreshBossBar(ServerPlayerEntity player, long nowEpochMillis) { + if (!configSupplier.get().hud().bountyBossbar().enabled()) { + clearBossBar(player); + return; + } + + List activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); + if (activeBounties.isEmpty()) { + clearBossBar(player); + return; + } + + long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); + long latestExpiry = activeBounties.stream().mapToLong(StoredBounty::expiresAtEpochMillis).max().orElse(nowEpochMillis); + long earliestCreated = activeBounties.stream().mapToLong(StoredBounty::createdAtEpochMillis).min().orElse(nowEpochMillis); + long remainingSeconds = Math.max(0L, (latestExpiry - nowEpochMillis + 999L) / 1000L); + long totalLifetime = Math.max(1L, latestExpiry - earliestCreated); + float percent = Math.max(0.0F, Math.min(1.0F, (latestExpiry - nowEpochMillis) / (float) totalLifetime)); + + ServerBossBar bossBar = bountyBossBars.computeIfAbsent(player.getUuid(), uuid -> new ServerBossBar( + Text.literal(configSupplier.get().hud().bountyBossbar().title()), + BossBar.Color.RED, + BossBar.Style.PROGRESS + )); + bossBar.setName(Text.literal(configSupplier.get().hud().bountyBossbar().title() + ": " + totalValue + " souls | " + DurationFormatter.formatSeconds(remainingSeconds))); + bossBar.setPercent(percent); + bossBar.setVisible(true); + if (!bossBar.getPlayers().contains(player)) { + bossBar.addPlayer(player); + } + } + + private List buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { + List activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); + long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); + long remainingSeconds = activeBounties.stream() + .mapToLong(StoredBounty::expiresAtEpochMillis) + .max() + .orElse(nowEpochMillis); + remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); + + return List.of( + Text.literal("Souls: " + soulService.balanceOf(player.getUuid())), + Text.literal("Bounties: " + activeBounties.size()), + Text.literal("Wanted Value: " + totalValue), + Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) + ); + } + + private void clearSidebar(ServerPlayerEntity player) { + SidebarState state = sidebars.remove(player.getUuid()); + if (state == null) { + return; + } + + for (String holder : state.holders()) { + player.networkHandler.sendPacket(new ScoreboardScoreResetS2CPacket(holder, state.objectiveName())); + } + player.networkHandler.sendPacket(new ScoreboardObjectiveUpdateS2CPacket(createObjective(state.objectiveName()), ScoreboardObjectiveUpdateS2CPacket.REMOVE_MODE)); + } + + private void clearBossBar(ServerPlayerEntity player) { + ServerBossBar bossBar = bountyBossBars.remove(player.getUuid()); + if (bossBar == null) { + return; + } + + bossBar.removePlayer(player); + bossBar.clearPlayers(); + } + + private ScoreboardObjective createObjective(String objectiveName) { + Scoreboard scoreboard = new Scoreboard(); + return scoreboard.addObjective( + objectiveName, + ScoreboardCriterion.DUMMY, + Text.literal(configSupplier.get().hud().scoreboard().title()), + ScoreboardCriterion.RenderType.INTEGER, + false, + BlankNumberFormat.INSTANCE + ); + } + + private void rememberPlayer(ServerPlayerEntity player) { + String playerKey = key(player.getUuid()); + String playerName = player.getName().getString(); + String previous = dataStore.data().playerNames().put(playerKey, playerName); + if (!Objects.equals(previous, playerName)) { + saveQuietly(); + } + } + + private String displayName(UUID playerUuid) { + return dataStore.data().playerNames().getOrDefault(key(playerUuid), playerUuid.toString()); + } + + private String objectiveName(UUID playerUuid) { + String raw = playerUuid.toString().replace("-", ""); + return "ss" + raw.substring(0, Math.min(14, raw.length())); + } + + private void saveQuietly() { + try { + dataStore.save(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to persist Soul Steal HUD data.", exception); + } + } + + private static String key(UUID playerUuid) { + return playerUuid.toString(); + } + + private record SidebarState(String objectiveName, List holders) { + } + + public record LeaderboardEntry(String playerName, long souls) { + } + + public record LeaderboardPage(int page, int totalPages, List entries) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/PermissionService.java b/src/main/java/com/g2806/soulsteal/service/PermissionService.java new file mode 100644 index 0000000..cac4312 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/PermissionService.java @@ -0,0 +1,197 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.SoulStealMod; +import com.g2806.soulsteal.data.SoulStealDataStore; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; + +/** + * Bridges Soul Steal's permission checks to the Fabric Permissions API and optional LuckPerms. + * + *

Permission rewards first try LuckPerms for external integrations, then optionally fall back to + * a persisted internal store so Soul Steal's own nodes continue to work without extra mods.

+ */ +public final class PermissionService { + private final SoulStealDataStore dataStore; + + public PermissionService(SoulStealDataStore dataStore) { + this.dataStore = dataStore; + } + + public boolean has(ServerCommandSource source, String permission, int defaultLevel) { + if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) { + return true; + } + + Boolean reflectedResult = invokePermissionsCheck(source, permission, defaultLevel); + if (reflectedResult != null) { + return reflectedResult; + } + + return source.getPlayer() == null || defaultLevel <= 0; + } + + public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) { + for (String permission : permissions) { + if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) { + return true; + } + } + return false; + } + + public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) { + if (hasStoredPermission(player.getUuid(), permission)) { + return true; + } + + Boolean reflectedResult = invokePermissionsCheck(player, permission, defaultValue); + if (reflectedResult != null) { + return reflectedResult; + } + + return defaultValue; + } + + public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) { + for (String permission : permissions) { + if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) { + return true; + } + } + return false; + } + + public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) { + boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value); + boolean storedInternally = false; + + if (storeFallback) { + Map storedPermissions = dataStore.data().grantedPermissions() + .computeIfAbsent(key(playerUuid), ignored -> new HashMap<>()); + if (value) { + storedPermissions.put(permission, true); + } else { + storedPermissions.remove(permission); + } + saveQuietly(); + storedInternally = true; + } + + if (grantedViaLuckPerms || storedInternally) { + return new GrantResult(true, grantedViaLuckPerms, storedInternally, + grantedViaLuckPerms ? "Permission granted successfully." : "Permission stored in Soul Steal fallback permissions."); + } + + return new GrantResult(false, false, false, + "No supported permissions backend was available for that reward."); + } + + private boolean hasStoredPermission(UUID playerUuid, String permission) { + return dataStore.data().grantedPermissions() + .getOrDefault(key(playerUuid), Map.of()) + .getOrDefault(permission, false); + } + + private boolean tryGrantWithLuckPerms(UUID playerUuid, String permission, boolean value) { + try { + Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = providerClass.getMethod("get").invoke(null); + Object userManager = api.getClass().getMethod("getUserManager").invoke(api); + Class permissionNodeClass = Class.forName("net.luckperms.api.node.types.PermissionNode"); + Object builder = permissionNodeClass.getMethod("builder", String.class).invoke(null, permission); + builder = builder.getClass().getMethod("value", boolean.class).invoke(builder, value); + Object builtNode = builder.getClass().getMethod("build").invoke(builder); + Class nodeClass = Class.forName("net.luckperms.api.node.Node"); + + Consumer consumer = user -> { + try { + Object data = user.getClass().getMethod("data").invoke(user); + data.getClass().getMethod("add", nodeClass).invoke(data, builtNode); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) { + throw new RuntimeException(exception); + } + }; + + userManager.getClass().getMethod("modifyUser", UUID.class, Consumer.class).invoke(userManager, playerUuid, consumer); + return true; + } catch (ClassNotFoundException exception) { + return false; + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) { + SoulStealMod.LOGGER.warn("Failed to grant LuckPerms permission {} to {}", permission, playerUuid, exception); + return false; + } + } + + private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) { + try { + Class permissionsClass = Class.forName("me.lucko.fabric.api.permissions.v0.Permissions"); + Class subjectType = subject.getClass(); + Method matchingMethod = null; + + for (Method method : permissionsClass.getMethods()) { + if (!method.getName().equals("check") || method.getParameterCount() != 3) { + continue; + } + + Class[] parameterTypes = method.getParameterTypes(); + if (parameterTypes[0].isAssignableFrom(subjectType) + && parameterTypes[1] == String.class + && wrap(parameterTypes[2]).isInstance(defaultValue)) { + matchingMethod = method; + break; + } + } + + if (matchingMethod == null) { + return null; + } + + return (Boolean) matchingMethod.invoke(null, subject, permission, defaultValue); + } catch (ClassNotFoundException exception) { + return null; + } catch (IllegalAccessException | InvocationTargetException exception) { + SoulStealMod.LOGGER.warn("Failed to query Fabric permissions for {}", permission, exception); + return null; + } + } + + private void saveQuietly() { + try { + dataStore.save(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to persist permission reward data.", exception); + } + } + + private static String key(UUID playerUuid) { + return playerUuid.toString(); + } + + private static Class wrap(Class type) { + if (!type.isPrimitive()) { + return type; + } + if (type == int.class) { + return Integer.class; + } + if (type == boolean.class) { + return Boolean.class; + } + if (type == long.class) { + return Long.class; + } + return type; + } + + public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/RewardService.java b/src/main/java/com/g2806/soulsteal/service/RewardService.java new file mode 100644 index 0000000..5516c4b --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/RewardService.java @@ -0,0 +1,251 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.shop.CommandRewardDefinition; +import com.g2806.soulsteal.shop.EffectRewardDefinition; +import com.g2806.soulsteal.shop.ItemRewardDefinition; +import com.g2806.soulsteal.shop.PermissionRewardDefinition; +import com.g2806.soulsteal.shop.RewardDefinition; +import com.g2806.soulsteal.shop.StackMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.LoreComponent; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.registry.Registries; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; + +/** Executes validated shop rewards for the player who bought an entry. */ +public final class RewardService { + private final PermissionService permissionService; + private final SoulService soulService; + + public RewardService(PermissionService permissionService, SoulService soulService) { + this.permissionService = permissionService; + this.soulService = soulService; + } + + public ValidationResult validateRewards(List rewards) { + for (RewardDefinition reward : rewards) { + switch (reward) { + case ItemRewardDefinition itemReward -> { + if (resolveItem(itemReward.itemId()) == null) { + return new ValidationResult(false, "Unknown item id: " + itemReward.itemId()); + } + } + case EffectRewardDefinition effectReward -> { + if (resolveStatusEffect(effectReward.effectId()) == null) { + return new ValidationResult(false, "Unknown status effect id: " + effectReward.effectId()); + } + } + case PermissionRewardDefinition permissionReward -> { + if (permissionReward.node().isBlank()) { + return new ValidationResult(false, "Permission rewards require a non-empty node."); + } + } + case CommandRewardDefinition commandReward -> { + if (commandReward.command().isBlank()) { + return new ValidationResult(false, "Command rewards require a non-empty command string."); + } + } + } + } + + return new ValidationResult(true, "Rewards validated successfully."); + } + + public GrantResult grantRewards(ServerPlayerEntity player, List rewards) { + List granted = new ArrayList<>(); + + for (RewardDefinition reward : rewards) { + switch (reward) { + case ItemRewardDefinition itemReward -> { + Item item = resolveItem(itemReward.itemId()); + if (item == null) { + return new GrantResult(false, "Unknown item id: " + itemReward.itemId(), granted); + } + giveItems(player, item, itemReward.amount()); + granted.add(itemReward.amount() + "x " + rewardDisplayName(itemReward)); + } + case EffectRewardDefinition effectReward -> { + RegistryEntry effectEntry = resolveStatusEffect(effectReward.effectId()); + if (effectEntry == null) { + return new GrantResult(false, "Unknown status effect id: " + effectReward.effectId(), granted); + } + applyEffectReward(player, effectEntry, effectReward); + granted.add(rewardDisplayName(effectReward)); + } + case PermissionRewardDefinition permissionReward -> { + PermissionService.GrantResult result = permissionService.grantPersistentPermission( + player.getUuid(), permissionReward.node(), permissionReward.value(), permissionReward.storeFallback()); + if (!result.success()) { + return new GrantResult(false, result.message(), granted); + } + granted.add(rewardDisplayName(permissionReward)); + } + case CommandRewardDefinition commandReward -> { + executeCommandReward(player, commandReward); + granted.add(rewardDisplayName(commandReward)); + } + } + } + + return new GrantResult(true, "Rewards granted successfully.", granted); + } + + public List describeRewards(List rewards) { + List lines = new ArrayList<>(); + for (RewardDefinition reward : rewards) { + switch (reward) { + case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY)); + case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY)); + case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY)); + case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY)); + } + } + return lines; + } + + public boolean supportsCustomAmount(List rewards) { + return !rewards.isEmpty() && rewards.stream().allMatch(ItemRewardDefinition.class::isInstance); + } + + public List scaleItemRewards(List rewards, int multiplier) { + if (multiplier <= 1) { + return rewards; + } + + List scaledRewards = new ArrayList<>(rewards.size()); + for (RewardDefinition reward : rewards) { + if (reward instanceof ItemRewardDefinition itemReward) { + long scaledAmount = (long) itemReward.amount() * multiplier; + scaledRewards.add(new ItemRewardDefinition(itemReward.itemId(), (int) Math.min(Integer.MAX_VALUE, scaledAmount), itemReward.displayName())); + continue; + } + + scaledRewards.add(reward); + } + + return scaledRewards; + } + + public ItemStack createPreviewStack(String itemId, String displayName, List loreLines) { + Item item = Objects.requireNonNullElse(resolveItem(itemId), Items.PAPER); + ItemStack stack = new ItemStack(item); + stack.set(DataComponentTypes.CUSTOM_NAME, Text.literal(displayName).formatted(Formatting.GOLD)); + if (!loreLines.isEmpty()) { + stack.set(DataComponentTypes.LORE, new LoreComponent(loreLines)); + } + return stack; + } + + private void giveItems(PlayerEntity player, Item item, int amount) { + int remaining = amount; + while (remaining > 0) { + int stackSize = Math.min(item.getMaxCount(), remaining); + player.giveItemStack(new ItemStack(item, stackSize)); + remaining -= stackSize; + } + } + + private void applyEffectReward(ServerPlayerEntity player, RegistryEntry effectEntry, EffectRewardDefinition definition) { + int durationTicks = definition.durationSeconds() * 20; + int amplifier = definition.amplifier(); + StatusEffectInstance existing = player.getStatusEffect(effectEntry); + + if (existing != null) { + if (definition.stackMode() == StackMode.ADD_DURATION) { + durationTicks += existing.getDuration(); + amplifier = Math.max(amplifier, existing.getAmplifier()); + } else if (definition.stackMode() == StackMode.MAX_DURATION) { + durationTicks = Math.max(durationTicks, existing.getDuration()); + amplifier = Math.max(amplifier, existing.getAmplifier()); + } + } + + StatusEffectInstance instance = new StatusEffectInstance( + effectEntry, + durationTicks, + amplifier, + definition.ambient(), + definition.showParticles(), + definition.showIcon() + ); + player.addStatusEffect(instance); + } + + private void executeCommandReward(ServerPlayerEntity player, CommandRewardDefinition definition) { + MinecraftServer server = Objects.requireNonNull(player.getEntityWorld().getServer(), "player server"); + ServerCommandSource source = definition.runAsConsole() ? server.getCommandSource() : player.getCommandSource(); + server.getCommandManager().parseAndExecute(source, expandPlaceholders(definition.command(), player)); + } + + private String rewardDisplayName(ItemRewardDefinition reward) { + if (reward.displayName() != null && !reward.displayName().isBlank()) { + return reward.displayName(); + } + return reward.itemId(); + } + + private String rewardDisplayName(EffectRewardDefinition reward) { + if (reward.displayName() != null && !reward.displayName().isBlank()) { + return reward.displayName(); + } + return reward.effectId(); + } + + private String rewardDisplayName(PermissionRewardDefinition reward) { + if (reward.displayName() != null && !reward.displayName().isBlank()) { + return reward.displayName(); + } + return "permission " + reward.node(); + } + + private String rewardDisplayName(CommandRewardDefinition reward) { + if (reward.displayName() != null && !reward.displayName().isBlank()) { + return reward.displayName(); + } + return "command hook"; + } + + private String expandPlaceholders(String command, ServerPlayerEntity player) { + return command + .replace("/", "") + .replace("%player%", player.getName().getString()) + .replace("%uuid%", player.getUuidAsString()) + .replace("%souls%", Long.toString(soulService.balanceOf(player.getUuid()))); + } + + private Item resolveItem(String itemId) { + Identifier identifier = Identifier.tryParse(itemId); + if (identifier == null || !Registries.ITEM.containsId(identifier)) { + return null; + } + return Registries.ITEM.get(identifier); + } + + private RegistryEntry resolveStatusEffect(String effectId) { + Identifier identifier = Identifier.tryParse(effectId); + if (identifier == null) { + return null; + } + return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null); + } + + public record ValidationResult(boolean success, String message) { + } + + public record GrantResult(boolean success, String message, List grantedRewards) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/ShopService.java b/src/main/java/com/g2806/soulsteal/service/ShopService.java new file mode 100644 index 0000000..7f5f54f --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/ShopService.java @@ -0,0 +1,528 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.config.ConfigBundle; +import com.g2806.soulsteal.data.SoulStealDataStore; +import com.g2806.soulsteal.shop.RewardDefinition; +import com.g2806.soulsteal.shop.ShopCatalog; +import com.g2806.soulsteal.shop.ShopCategoryDefinition; +import com.g2806.soulsteal.shop.ShopEntryDefinition; +import com.g2806.soulsteal.shop.SoulShopScreenHandler; +import com.g2806.soulsteal.util.DurationFormatter; +import com.g2806.soulsteal.util.SoulTexts; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.inventory.SimpleInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.screen.SimpleNamedScreenHandlerFactory; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +/** + * Builds the server-side soul shop GUI and executes purchases. + * + *

The GUI uses vanilla generic container screen types so players do not need any client-side + * installation.

+ */ +public final class ShopService { + private static final int QUANTITY_ROWS = 3; + private static final int SLOT_MINUS_SIXTEEN = 10; + private static final int SLOT_MINUS_EIGHT = 11; + private static final int SLOT_MINUS_ONE = 12; + private static final int SLOT_PREVIEW = 13; + private static final int SLOT_PLUS_ONE = 14; + private static final int SLOT_PLUS_EIGHT = 15; + private static final int SLOT_PLUS_SIXTEEN = 16; + private static final int SLOT_BACK = 18; + private static final int SLOT_MAX = 20; + private static final int SLOT_CONFIRM = 22; + + private final Supplier bundleSupplier; + private final SoulService soulService; + private final RewardService rewardService; + private final SoulStealDataStore dataStore; + + public ShopService( + Supplier bundleSupplier, + SoulService soulService, + RewardService rewardService, + SoulStealDataStore dataStore + ) { + this.bundleSupplier = bundleSupplier; + this.soulService = soulService; + this.rewardService = rewardService; + this.dataStore = dataStore; + } + + public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) { + if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) { + openView(player, resolveHomeView(requestedPage)); + return; + } + + openView(player, resolveCategoryView(requestedCategoryKey, requestedPage)); + } + + public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) { + return switch (view) { + case HomeView homeView -> createHomeInventory(player, homeView); + case CategoryView categoryView -> createCategoryInventory(player, categoryView); + case AmountView amountView -> createAmountInventory(player, amountView); + }; + } + + public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) { + switch (view) { + case HomeView homeView -> handleHomeClick(player, homeView, slotIndex); + case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex); + case AmountView amountView -> handleAmountClick(player, amountView, slotIndex); + } + } + + private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) { + int controlBase = view.itemSlotsPerPage(); + + if (slotIndex >= 0 && slotIndex < view.visibleCategories().size()) { + openView(player, resolveCategoryView(view.visibleCategories().get(slotIndex).key(), 0)); + return; + } + + if (slotIndex == controlBase + 1) { + openView(player, resolveHomeView(Math.max(0, view.pageIndex() - 1))); + } else if (slotIndex == controlBase + 7) { + openView(player, resolveHomeView(Math.min(view.totalPages() - 1, view.pageIndex() + 1))); + } + } + + private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) { + int controlBase = view.itemSlotsPerPage(); + + if (slotIndex >= 0 && slotIndex < view.visibleEntries().size()) { + ShopEntryDefinition entry = view.visibleEntries().get(slotIndex); + int maxAmount = maxPurchasableAmount(player, entry); + if (maxAmount > 1) { + openView(player, resolveAmountView(player, view.category().key(), view.pageIndex(), entry.id(), 1)); + return; + } + + PurchaseResult result = purchase(player, view.category().key(), entry, 1); + player.sendMessage(result.success() ? SoulTexts.success(result.message()) : SoulTexts.error(result.message()), false); + openView(player, resolveCategoryView(view.category().key(), view.pageIndex())); + return; + } + + if (slotIndex == controlBase) { + openView(player, resolveHomeView(0)); + } else if (slotIndex == controlBase + 1) { + openView(player, resolveCategoryView(view.category().key(), Math.max(0, view.pageIndex() - 1))); + } else if (slotIndex == controlBase + 7) { + openView(player, resolveCategoryView(view.category().key(), Math.min(view.totalPages() - 1, view.pageIndex() + 1))); + } + } + + private void handleAmountClick(ServerPlayerEntity player, AmountView view, int slotIndex) { + if (slotIndex == SLOT_BACK) { + openView(player, resolveCategoryView(view.category().key(), view.returnPageIndex())); + return; + } + + if (slotIndex == SLOT_MAX) { + openView(player, resolveAmountView(player, view.category().key(), view.returnPageIndex(), view.entry().id(), view.maxQuantity())); + return; + } + + if (slotIndex == SLOT_CONFIRM) { + PurchaseResult result = purchase(player, view.category().key(), view.entry(), view.quantity()); + player.sendMessage(result.success() ? SoulTexts.success(result.message()) : SoulTexts.error(result.message()), false); + openView(player, resolveCategoryView(view.category().key(), view.returnPageIndex())); + return; + } + + int updatedQuantity = switch (slotIndex) { + case SLOT_MINUS_SIXTEEN -> view.quantity() - 16; + case SLOT_MINUS_EIGHT -> view.quantity() - 8; + case SLOT_MINUS_ONE -> view.quantity() - 1; + case SLOT_PLUS_ONE -> view.quantity() + 1; + case SLOT_PLUS_EIGHT -> view.quantity() + 8; + case SLOT_PLUS_SIXTEEN -> view.quantity() + 16; + default -> view.quantity(); + }; + + if (updatedQuantity != view.quantity()) { + openView(player, resolveAmountView(player, view.category().key(), view.returnPageIndex(), view.entry().id(), updatedQuantity)); + } + } + + private PurchaseResult purchase(ServerPlayerEntity player, String categoryKey, ShopEntryDefinition entry, int amountMultiplier) { + long now = System.currentTimeMillis(); + String purchaseKey = purchaseKey(categoryKey, entry); + + if (!entry.repeatable() && isUnlocked(player, purchaseKey)) { + return new PurchaseResult(false, "That shop entry is already unlocked on your account."); + } + + long cooldownExpiry = purchaseCooldownExpiry(player, purchaseKey); + if (cooldownExpiry > now) { + long secondsRemaining = Math.max(1L, (cooldownExpiry - now + 999L) / 1000L); + return new PurchaseResult(false, "That item is on cooldown for another " + DurationFormatter.formatSeconds(secondsRemaining) + "."); + } + + int quantity = Math.max(1, amountMultiplier); + long totalCost = entry.cost() * quantity; + List rewards = rewardService.scaleItemRewards(entry.rewards(), quantity); + if (!soulService.hasSouls(player.getUuid(), totalCost)) { + return new PurchaseResult(false, "You do not have enough souls for that purchase."); + } + + RewardService.ValidationResult validation = rewardService.validateRewards(rewards); + if (!validation.success()) { + return new PurchaseResult(false, validation.message()); + } + + soulService.removeSouls(player.getUuid(), totalCost); + RewardService.GrantResult grantResult = rewardService.grantRewards(player, rewards); + if (!grantResult.success()) { + soulService.addSouls(player.getUuid(), totalCost); + return new PurchaseResult(false, grantResult.message()); + } + + if (!entry.repeatable()) { + dataStore.data().unlockedEntries() + .computeIfAbsent(player.getUuidAsString(), ignored -> new java.util.HashSet<>()) + .add(purchaseKey); + } + + if (entry.cooldownSeconds() > 0L) { + dataStore.data().purchaseCooldowns() + .computeIfAbsent(player.getUuidAsString(), ignored -> new HashMap<>()) + .put(purchaseKey, now + (entry.cooldownSeconds() * 1000L)); + } + + saveQuietly(); + String quantityLabel = quantity > 1 ? quantity + "x " : ""; + return new PurchaseResult(true, "Purchased " + quantityLabel + entry.name() + " for " + totalCost + " souls."); + } + + private HomeView resolveHomeView(int requestedPage) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + List categories = catalog.categories(); + if (categories.isEmpty()) { + throw new IllegalStateException("The configured shop does not contain any categories."); + } + + int itemSlotsPerPage = pageSize(catalog.rows()); + int totalPages = Math.max(1, (int) Math.ceil(categories.size() / (double) itemSlotsPerPage)); + int page = Math.max(0, Math.min(totalPages - 1, requestedPage)); + int fromIndex = page * itemSlotsPerPage; + int toIndex = Math.min(categories.size(), fromIndex + itemSlotsPerPage); + List visibleCategories = new ArrayList<>(categories.subList(fromIndex, toIndex)); + return new HomeView(catalog.title(), catalog.rows(), page, totalPages, itemSlotsPerPage, categories.size(), visibleCategories); + } + + private CategoryView resolveCategoryView(String requestedCategoryKey, int requestedPage) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + ShopCategoryDefinition category = catalog.category(requestedCategoryKey) + .orElseGet(() -> catalog.categories().isEmpty() ? null : catalog.categories().get(0)); + if (category == null) { + throw new IllegalStateException("The configured shop does not contain any categories."); + } + + List entries = category.entries().stream() + .sorted(Comparator.comparingInt(ShopEntryDefinition::slot).thenComparing(ShopEntryDefinition::id)) + .toList(); + int itemSlotsPerPage = pageSize(catalog.rows()); + int totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) itemSlotsPerPage)); + int page = Math.max(0, Math.min(totalPages - 1, requestedPage)); + int fromIndex = page * itemSlotsPerPage; + int toIndex = Math.min(entries.size(), fromIndex + itemSlotsPerPage); + List visibleEntries = new ArrayList<>(entries.subList(fromIndex, toIndex)); + return new CategoryView(catalog.title(), catalog.rows(), category, page, totalPages, itemSlotsPerPage, visibleEntries); + } + + private AmountView resolveAmountView(ServerPlayerEntity player, String categoryKey, int returnPageIndex, String entryId, int requestedQuantity) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + ShopCategoryDefinition category = requireCategory(catalog, categoryKey); + ShopEntryDefinition entry = requireEntry(category, entryId); + int maxQuantity = Math.max(1, maxPurchasableAmount(player, entry)); + int quantity = Math.max(1, Math.min(maxQuantity, requestedQuantity)); + return new AmountView(catalog.title(), category, returnPageIndex, entry, quantity, maxQuantity); + } + + private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + SimpleInventory inventory = filledInventory(view.rows(), catalog.fillerItemId()); + + for (int index = 0; index < view.visibleCategories().size(); index++) { + inventory.setStack(index, createCategoryStack(view.visibleCategories().get(index))); + } + + int controlBase = view.itemSlotsPerPage(); + inventory.setStack(controlBase + 1, createPageButton(view.pageIndex(), view.totalPages(), true)); + inventory.setStack(controlBase + 4, createHomeInfoButton(player, view)); + inventory.setStack(controlBase + 7, createPageButton(view.pageIndex(), view.totalPages(), false)); + return inventory; + } + + private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + SimpleInventory inventory = filledInventory(view.rows(), catalog.fillerItemId()); + + for (int index = 0; index < view.visibleEntries().size(); index++) { + inventory.setStack(index, createEntryStack(player, view.category().key(), view.visibleEntries().get(index))); + } + + int controlBase = view.itemSlotsPerPage(); + inventory.setStack(controlBase, createHomeButton()); + inventory.setStack(controlBase + 1, createPageButton(view.pageIndex(), view.totalPages(), true)); + inventory.setStack(controlBase + 4, createCategoryInfoButton(player, view)); + inventory.setStack(controlBase + 7, createPageButton(view.pageIndex(), view.totalPages(), false)); + return inventory; + } + + private SimpleInventory createAmountInventory(ServerPlayerEntity player, AmountView view) { + ShopCatalog catalog = bundleSupplier.get().shopCatalog(); + SimpleInventory inventory = filledInventory(QUANTITY_ROWS, catalog.fillerItemId()); + + inventory.setStack(SLOT_MINUS_SIXTEEN, createAdjustmentButton(-16, view.quantity() > 1)); + inventory.setStack(SLOT_MINUS_EIGHT, createAdjustmentButton(-8, view.quantity() > 1)); + inventory.setStack(SLOT_MINUS_ONE, createAdjustmentButton(-1, view.quantity() > 1)); + inventory.setStack(SLOT_PREVIEW, createAmountPreviewStack(player, view)); + inventory.setStack(SLOT_PLUS_ONE, createAdjustmentButton(1, view.quantity() < view.maxQuantity())); + inventory.setStack(SLOT_PLUS_EIGHT, createAdjustmentButton(8, view.quantity() < view.maxQuantity())); + inventory.setStack(SLOT_PLUS_SIXTEEN, createAdjustmentButton(16, view.quantity() < view.maxQuantity())); + inventory.setStack(SLOT_BACK, rewardService.createPreviewStack("minecraft:barrier", "Back", List.of( + Text.literal("Return to " + view.category().name()).formatted(Formatting.GRAY) + ))); + inventory.setStack(SLOT_MAX, rewardService.createPreviewStack("minecraft:hopper", "Use Max Amount", List.of( + Text.literal("Current max: " + view.maxQuantity()).formatted(Formatting.GRAY) + ))); + inventory.setStack(SLOT_CONFIRM, rewardService.createPreviewStack("minecraft:emerald", "Confirm Purchase", List.of( + Text.literal("Total cost: " + (view.entry().cost() * (long) view.quantity()) + " souls").formatted(Formatting.GOLD), + Text.literal("Buy " + view.quantity() + " bundle(s).").formatted(Formatting.AQUA) + ))); + return inventory; + } + + private ItemStack createCategoryStack(ShopCategoryDefinition category) { + return rewardService.createPreviewStack(category.iconItemId(), category.name(), List.of( + Text.literal("Open this category.").formatted(Formatting.GRAY) + )); + } + + private ItemStack createEntryStack(ServerPlayerEntity player, String categoryKey, ShopEntryDefinition entry) { + String purchaseKey = purchaseKey(categoryKey, entry); + List lore = new ArrayList<>(); + for (String line : entry.description()) { + lore.add(Text.literal(line).formatted(Formatting.GRAY)); + } + lore.add(Text.literal("Cost: " + entry.cost() + " souls").formatted(Formatting.GOLD)); + lore.addAll(rewardService.describeRewards(entry.rewards())); + + if (canUseCustomAmount(entry)) { + lore.add(Text.literal("Custom amount: up to " + entry.maxCustomAmount() + " bundles per purchase").formatted(Formatting.AQUA)); + } + + if (!entry.repeatable()) { + lore.add(Text.literal(isUnlocked(player, purchaseKey) ? "Status: Already unlocked" : "Status: One-time unlock").formatted(Formatting.AQUA)); + } + + long cooldownExpiry = purchaseCooldownExpiry(player, purchaseKey); + if (cooldownExpiry > System.currentTimeMillis()) { + long secondsRemaining = Math.max(1L, (cooldownExpiry - System.currentTimeMillis() + 999L) / 1000L); + lore.add(Text.literal("Cooldown: " + DurationFormatter.formatSeconds(secondsRemaining)).formatted(Formatting.RED)); + } else if (entry.cooldownSeconds() > 0L) { + lore.add(Text.literal("Cooldown after use: " + DurationFormatter.formatSeconds(entry.cooldownSeconds())).formatted(Formatting.DARK_GRAY)); + } + + return rewardService.createPreviewStack(entry.iconItemId(), entry.name(), lore); + } + + private ItemStack createAmountPreviewStack(ServerPlayerEntity player, AmountView view) { + List lore = new ArrayList<>(); + lore.add(Text.literal("Selected amount: " + view.quantity()).formatted(Formatting.AQUA)); + lore.add(Text.literal("Total cost: " + (view.entry().cost() * (long) view.quantity()) + " souls").formatted(Formatting.GOLD)); + lore.add(Text.literal("Your balance: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GRAY)); + lore.addAll(rewardService.describeRewards(rewardService.scaleItemRewards(view.entry().rewards(), view.quantity()))); + return rewardService.createPreviewStack(view.entry().iconItemId(), view.entry().name(), lore); + } + + private ItemStack createFillerStack(String fillerItemId) { + ItemStack filler = rewardService.createPreviewStack(fillerItemId, " ", List.of()); + filler.remove(DataComponentTypes.LORE); + return filler; + } + + private ItemStack createPageButton(int pageIndex, int totalPages, boolean previous) { + boolean available = previous ? pageIndex > 0 : pageIndex < totalPages - 1; + String label = previous ? "Previous Page" : "Next Page"; + List lore = new ArrayList<>(); + lore.add(Text.literal("Page " + (pageIndex + 1) + " of " + totalPages).formatted(Formatting.GRAY)); + lore.add(Text.literal(available ? "Click to switch pages." : "No more pages in this direction.").formatted(available ? Formatting.AQUA : Formatting.DARK_GRAY)); + return rewardService.createPreviewStack("minecraft:arrow", label, lore); + } + + private ItemStack createHomeButton() { + return rewardService.createPreviewStack("minecraft:compass", "Category Home", List.of( + Text.literal("Return to the category overview.").formatted(Formatting.GRAY) + )); + } + + private ItemStack createHomeInfoButton(ServerPlayerEntity player, HomeView view) { + return rewardService.createPreviewStack("minecraft:nether_star", "Shop Home", List.of( + Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD), + Text.literal("Categories: " + view.categoryCount()).formatted(Formatting.AQUA), + Text.literal("Page " + (view.pageIndex() + 1) + " / " + view.totalPages()).formatted(Formatting.GRAY) + )); + } + + private ItemStack createCategoryInfoButton(ServerPlayerEntity player, CategoryView view) { + return rewardService.createPreviewStack("minecraft:nether_star", view.category().name(), List.of( + Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD), + Text.literal("Items on this page: " + view.visibleEntries().size()).formatted(Formatting.AQUA), + Text.literal("Page " + (view.pageIndex() + 1) + " / " + view.totalPages()).formatted(Formatting.GRAY) + )); + } + + private ItemStack createAdjustmentButton(int amount, boolean available) { + String label = (amount > 0 ? "+" : "") + amount; + List lore = List.of(Text.literal(available ? "Click to adjust the amount." : "This amount is not available right now.") + .formatted(available ? Formatting.GRAY : Formatting.DARK_GRAY)); + return rewardService.createPreviewStack(available ? (amount > 0 ? "minecraft:arrow" : "minecraft:spectral_arrow") : "minecraft:barrier", label, lore); + } + + private SimpleInventory filledInventory(int rows, String fillerItemId) { + SimpleInventory inventory = new SimpleInventory(rows * 9); + ItemStack filler = createFillerStack(fillerItemId); + for (int slot = 0; slot < inventory.size(); slot++) { + inventory.setStack(slot, filler.copy()); + } + return inventory; + } + + private boolean canUseCustomAmount(ShopEntryDefinition entry) { + return bundleSupplier.get().config().shop().customAmountSelectorEnabled() + && entry.allowCustomAmount() + && rewardService.supportsCustomAmount(entry.rewards()); + } + + private int maxPurchasableAmount(ServerPlayerEntity player, ShopEntryDefinition entry) { + if (!canUseCustomAmount(entry)) { + return 1; + } + + long maxAffordable = entry.cost() <= 0L ? entry.maxCustomAmount() : soulService.balanceOf(player.getUuid()) / entry.cost(); + return (int) Math.max(1L, Math.min(entry.maxCustomAmount(), maxAffordable)); + } + + private boolean isUnlocked(ServerPlayerEntity player, String purchaseKey) { + return dataStore.data().unlockedEntries() + .getOrDefault(player.getUuidAsString(), java.util.Set.of()) + .contains(purchaseKey); + } + + private long purchaseCooldownExpiry(ServerPlayerEntity player, String purchaseKey) { + return dataStore.data().purchaseCooldowns() + .getOrDefault(player.getUuidAsString(), Map.of()) + .getOrDefault(purchaseKey, 0L); + } + + private ShopCategoryDefinition requireCategory(ShopCatalog catalog, String categoryKey) { + return catalog.category(categoryKey) + .orElseThrow(() -> new IllegalArgumentException("Unknown shop category: " + categoryKey)); + } + + private ShopEntryDefinition requireEntry(ShopCategoryDefinition category, String entryId) { + return category.entries().stream() + .filter(entry -> entry.id().equalsIgnoreCase(entryId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown shop entry: " + entryId)); + } + + private String purchaseKey(String categoryKey, ShopEntryDefinition entry) { + return categoryKey + ":" + entry.id(); + } + + private int pageSize(int rows) { + return Math.max(1, (rows - 1) * 9); + } + + private void openView(ServerPlayerEntity player, ShopView view) { + player.openHandledScreen(new SimpleNamedScreenHandlerFactory( + (syncId, playerInventory, ignored) -> new SoulShopScreenHandler(syncId, playerInventory, this, view), + Text.literal(view.title()) + )); + } + + private void saveQuietly() { + try { + dataStore.save(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to persist shop purchase data.", exception); + } + } + + public sealed interface ShopView permits HomeView, CategoryView, AmountView { + String title(); + + int rows(); + } + + public record HomeView( + String catalogTitle, + int rows, + int pageIndex, + int totalPages, + int itemSlotsPerPage, + int categoryCount, + List visibleCategories + ) implements ShopView { + @Override + public String title() { + return catalogTitle; + } + } + + public record CategoryView( + String catalogTitle, + int rows, + ShopCategoryDefinition category, + int pageIndex, + int totalPages, + int itemSlotsPerPage, + List visibleEntries + ) implements ShopView { + @Override + public String title() { + return catalogTitle + " - " + category.name(); + } + } + + public record AmountView( + String catalogTitle, + ShopCategoryDefinition category, + int returnPageIndex, + ShopEntryDefinition entry, + int quantity, + int maxQuantity + ) implements ShopView { + @Override + public String title() { + return catalogTitle + " - " + entry.name(); + } + + @Override + public int rows() { + return QUANTITY_ROWS; + } + } + + public record PurchaseResult(boolean success, String message) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/SoulService.java b/src/main/java/com/g2806/soulsteal/service/SoulService.java new file mode 100644 index 0000000..9471d1b --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/SoulService.java @@ -0,0 +1,116 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.config.SoulStealConfig; +import com.g2806.soulsteal.data.SoulStealData; +import com.g2806.soulsteal.data.SoulStealDataStore; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; +import java.util.function.Supplier; + +/** Handles balance mutations for all features that spend or grant souls. */ +public final class SoulService { + private final Supplier configSupplier; + private final SoulStealDataStore dataStore; + + public SoulService(Supplier configSupplier, SoulStealDataStore dataStore) { + this.configSupplier = configSupplier; + this.dataStore = dataStore; + } + + public long balanceOf(UUID playerUuid) { + SoulStealConfig.EconomyConfig economy = configSupplier.get().economy(); + return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls()); + } + + public boolean hasSouls(UUID playerUuid, long amount) { + return balanceOf(playerUuid) >= Math.max(0L, amount); + } + + public long addSouls(UUID playerUuid, long amount) { + if (amount <= 0L) { + return balanceOf(playerUuid); + } + + SoulStealConfig.EconomyConfig economy = configSupplier.get().economy(); + long current = balanceOf(playerUuid); + long updated = Math.min(economy.maxSouls(), current + amount); + updateBalance(playerUuid, updated); + return updated; + } + + public long removeSouls(UUID playerUuid, long amount) { + if (amount <= 0L) { + return balanceOf(playerUuid); + } + + long current = balanceOf(playerUuid); + long updated = Math.max(0L, current - amount); + updateBalance(playerUuid, updated); + return updated; + } + + public void setSouls(UUID playerUuid, long amount) { + SoulStealConfig.EconomyConfig economy = configSupplier.get().economy(); + updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount))); + } + + public SoulChange applyDeathPenalty(UUID playerUuid) { + long current = balanceOf(playerUuid); + if (current <= 0L) { + return new SoulChange(0L, 0L); + } + + SoulStealConfig.DeathPenaltyConfig penalty = configSupplier.get().economy().deathPenalty(); + long computedLoss = penalty.flat() + Math.round(current * penalty.percent()); + long boundedLoss = Math.max(penalty.minimum(), computedLoss); + if (penalty.maximum() > 0L) { + boundedLoss = Math.min(boundedLoss, penalty.maximum()); + } + boundedLoss = Math.min(boundedLoss, current); + long newBalance = current - boundedLoss; + updateBalance(playerUuid, newBalance); + return new SoulChange(-boundedLoss, newBalance); + } + + public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) { + SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer(); + if (!transferConfig.enabled()) { + return new TransferResult(false, "Soul transfers are disabled on this server.", balanceOf(senderUuid), balanceOf(receiverUuid)); + } + if (amount < transferConfig.minimum()) { + return new TransferResult(false, "The amount is below the configured minimum transfer size.", balanceOf(senderUuid), balanceOf(receiverUuid)); + } + if (!hasSouls(senderUuid, amount)) { + return new TransferResult(false, "You do not have enough souls for that transfer.", balanceOf(senderUuid), balanceOf(receiverUuid)); + } + + removeSouls(senderUuid, amount); + addSouls(receiverUuid, amount); + return new TransferResult(true, "Transferred souls successfully.", balanceOf(senderUuid), balanceOf(receiverUuid)); + } + + private void updateBalance(UUID playerUuid, long newBalance) { + SoulStealData data = dataStore.data(); + data.souls().put(key(playerUuid), newBalance); + saveQuietly(); + } + + private void saveQuietly() { + try { + dataStore.save(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to persist Soul Steal data.", exception); + } + } + + private static String key(UUID playerUuid) { + return playerUuid.toString(); + } + + public record SoulChange(long delta, long newBalance) { + } + + public record TransferResult(boolean success, String message, long senderBalance, long receiverBalance) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java b/src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java new file mode 100644 index 0000000..0761af8 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java @@ -0,0 +1,153 @@ +package com.g2806.soulsteal.service; + +import com.g2806.soulsteal.config.SoulStealConfig; +import com.g2806.soulsteal.util.DurationFormatter; +import com.g2806.soulsteal.util.SoulTexts; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import net.minecraft.component.ComponentsAccess; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.LodestoneTrackerComponent; +import net.minecraft.component.type.LoreComponent; +import net.minecraft.component.type.NbtComponent; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.GlobalPos; + +/** + * Creates and maintains temporary tracker compasses after player kills. + * + *

Tracker metadata is stored on the compass item itself so the item remains functional across + * server restarts without needing a second persistence table.

+ */ +public final class TrackerCompassService { + private static final String TARGET_UUID_KEY = "SoulStealTargetUuid"; + private static final String TARGET_NAME_KEY = "SoulStealTargetName"; + private static final String EXPIRES_AT_KEY = "SoulStealTrackerExpiresAt"; + + private final Supplier configSupplier; + private int tickCounter; + + public TrackerCompassService(Supplier configSupplier) { + this.configSupplier = configSupplier; + } + + public void giveTrackerCompass(ServerPlayerEntity killer, ServerPlayerEntity target) { + SoulStealConfig.TrackerConfig trackerConfig = configSupplier.get().tracker(); + if (!trackerConfig.enabled()) { + return; + } + + long expiresAt = System.currentTimeMillis() + (trackerConfig.durationSeconds() * 1000L); + ItemStack compass = new ItemStack(Items.COMPASS); + applyTrackerData(compass, target, expiresAt); + killer.giveItemStack(compass); + killer.sendMessage(SoulTexts.info("You received a tracker compass for " + DurationFormatter.formatSeconds(trackerConfig.durationSeconds()) + "."), false); + } + + public void tick(MinecraftServer server) { + SoulStealConfig.TrackerConfig trackerConfig = configSupplier.get().tracker(); + if (!trackerConfig.enabled()) { + return; + } + + tickCounter++; + if (tickCounter < trackerConfig.updateIntervalTicks()) { + return; + } + tickCounter = 0; + + long now = System.currentTimeMillis(); + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + boolean removedExpiredCompass = false; + PlayerInventory inventory = player.getInventory(); + + for (int slot = 0; slot < inventory.size(); slot++) { + ItemStack stack = inventory.getStack(slot); + Optional state = readTrackerState(stack); + if (state.isEmpty()) { + continue; + } + + TrackerState trackerState = state.get(); + if (trackerState.expiresAtEpochMillis() <= now) { + inventory.setStack(slot, ItemStack.EMPTY); + removedExpiredCompass = true; + continue; + } + + ServerPlayerEntity target = server.getPlayerManager().getPlayer(trackerState.targetUuid()); + if (target == null) { + if (trackerConfig.expireIfTargetOffline()) { + inventory.setStack(slot, ItemStack.EMPTY); + removedExpiredCompass = true; + } + continue; + } + + applyTrackerData(stack, target, trackerState.expiresAtEpochMillis()); + } + + if (removedExpiredCompass) { + inventory.markDirty(); + player.sendMessage(SoulTexts.warning("A tracker compass expired and vanished from your inventory."), false); + } + } + } + + private void applyTrackerData(ItemStack stack, ServerPlayerEntity target, long expiresAtEpochMillis) { + stack.set(DataComponentTypes.CUSTOM_NAME, Text.literal("Soul Tracker: " + target.getName().getString()).formatted(Formatting.AQUA)); + stack.set(DataComponentTypes.LORE, new LoreComponent(List.of( + Text.literal("Tracks your last victim while the timer lasts.").formatted(Formatting.GRAY), + Text.literal("Expires in about " + DurationFormatter.formatSeconds(Math.max(1L, (expiresAtEpochMillis - System.currentTimeMillis()) / 1000L))).formatted(Formatting.DARK_GRAY) + ))); + stack.set(DataComponentTypes.LODESTONE_TRACKER, + new LodestoneTrackerComponent(Optional.of(GlobalPos.create(target.getEntityWorld().getRegistryKey(), target.getBlockPos())), false)); + NbtComponent.set(DataComponentTypes.CUSTOM_DATA, stack, nbt -> { + nbt.putString(TARGET_UUID_KEY, target.getUuidAsString()); + nbt.putString(TARGET_NAME_KEY, target.getName().getString()); + nbt.putLong(EXPIRES_AT_KEY, expiresAtEpochMillis); + }); + } + + private Optional readTrackerState(ItemStack stack) { + if (!stack.isOf(Items.COMPASS)) { + return Optional.empty(); + } + + NbtComponent customData = ((ComponentsAccess) stack).getOrDefault(DataComponentTypes.CUSTOM_DATA, NbtComponent.DEFAULT); + if (customData.isEmpty()) { + return Optional.empty(); + } + + NbtCompound compound = customData.copyNbt(); + if (!compound.contains(TARGET_UUID_KEY) || !compound.contains(EXPIRES_AT_KEY)) { + return Optional.empty(); + } + + String targetUuid = compound.getString(TARGET_UUID_KEY, ""); + String targetName = compound.getString(TARGET_NAME_KEY, "Unknown"); + long expiresAt = compound.getLong(EXPIRES_AT_KEY, 0L); + if (targetUuid.isBlank() || expiresAt <= 0L) { + return Optional.empty(); + } + + try { + return Optional.of(new TrackerState(UUID.fromString(targetUuid), targetName, expiresAt)); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + private record TrackerState(UUID targetUuid, String targetName, long expiresAtEpochMillis) { + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/CommandRewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/CommandRewardDefinition.java new file mode 100644 index 0000000..0fac67a --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/CommandRewardDefinition.java @@ -0,0 +1,12 @@ +package com.g2806.soulsteal.shop; + +/** + * Command reward executed after purchase. This is useful for integrations that are easier to wire + * through console commands than a dedicated API. + */ +public record CommandRewardDefinition(String command, boolean runAsConsole, String displayName) implements RewardDefinition { + @Override + public RewardType type() { + return RewardType.COMMAND; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/EffectRewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/EffectRewardDefinition.java new file mode 100644 index 0000000..9acc3c7 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/EffectRewardDefinition.java @@ -0,0 +1,18 @@ +package com.g2806.soulsteal.shop; + +/** Potion or status-effect reward that can stack or replace an active effect. */ +public record EffectRewardDefinition( + String effectId, + int durationSeconds, + int amplifier, + StackMode stackMode, + boolean ambient, + boolean showParticles, + boolean showIcon, + String displayName +) implements RewardDefinition { + @Override + public RewardType type() { + return RewardType.EFFECT; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/ItemRewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/ItemRewardDefinition.java new file mode 100644 index 0000000..f125c47 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/ItemRewardDefinition.java @@ -0,0 +1,9 @@ +package com.g2806.soulsteal.shop; + +/** Item reward declared in {@code shop.yml}. */ +public record ItemRewardDefinition(String itemId, int amount, String displayName) implements RewardDefinition { + @Override + public RewardType type() { + return RewardType.ITEM; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/PermissionRewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/PermissionRewardDefinition.java new file mode 100644 index 0000000..8b6191c --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/PermissionRewardDefinition.java @@ -0,0 +1,9 @@ +package com.g2806.soulsteal.shop; + +/** Permanent permission reward, typically granted through LuckPerms when it is installed. */ +public record PermissionRewardDefinition(String node, boolean value, boolean storeFallback, String displayName) implements RewardDefinition { + @Override + public RewardType type() { + return RewardType.PERMISSION; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java new file mode 100644 index 0000000..4f40079 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java @@ -0,0 +1,6 @@ +package com.g2806.soulsteal.shop; + +/** Marker interface for all shop reward definitions. */ +public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition { + RewardType type(); +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/RewardType.java b/src/main/java/com/g2806/soulsteal/shop/RewardType.java new file mode 100644 index 0000000..14281e9 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/RewardType.java @@ -0,0 +1,9 @@ +package com.g2806.soulsteal.shop; + +/** Supported reward types that can be granted by the soul shop. */ +public enum RewardType { + ITEM, + PERMISSION, + EFFECT, + COMMAND +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java b/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java new file mode 100644 index 0000000..3586b34 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java @@ -0,0 +1,229 @@ +package com.g2806.soulsteal.shop; + +import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig; +import com.g2806.soulsteal.config.YamlConfigHelper; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** Parsed representation of the editable shop catalog. */ +public record ShopCatalog(String title, int rows, String fillerItemId, List categories) { + public static ShopCatalog fromMap(Map root, ShopUiConfig shopUi) { + Map categoriesSection = YamlConfigHelper.section(root, "categories"); + List categories = new ArrayList<>(); + + for (Map.Entry categoryEntry : categoriesSection.entrySet()) { + if (!(categoryEntry.getValue() instanceof Map rawCategoryMap)) { + continue; + } + + Map categoryMap = toStringMap(rawCategoryMap); + Map itemsSection = YamlConfigHelper.section(categoryMap, "items"); + List shopEntries = new ArrayList<>(); + + for (Map.Entry itemEntry : itemsSection.entrySet()) { + if (!(itemEntry.getValue() instanceof Map rawItemMap)) { + continue; + } + + Map itemMap = toStringMap(rawItemMap); + List rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards")); + if (rewards.isEmpty()) { + continue; + } + + shopEntries.add(new ShopEntryDefinition( + itemEntry.getKey(), + Math.max(0, Math.min(53, YamlConfigHelper.intValue(itemMap, "slot", 0))), + YamlConfigHelper.string(itemMap, "icon", "minecraft:paper"), + YamlConfigHelper.string(itemMap, "name", itemEntry.getKey()), + YamlConfigHelper.stringList(itemMap, "description"), + Math.max(0L, YamlConfigHelper.longValue(itemMap, "cost", 0L)), + YamlConfigHelper.bool(itemMap, "repeatable", true), + Math.max(0L, YamlConfigHelper.longValue(itemMap, "cooldown_seconds", shopUi.defaultPurchaseCooldownSeconds())), + YamlConfigHelper.bool(itemMap, "allow_custom_amount", false), + Math.max(1, YamlConfigHelper.intValue(itemMap, "max_custom_amount", shopUi.defaultMaxCustomAmount())), + rewards + )); + } + + categories.add(new ShopCategoryDefinition( + categoryEntry.getKey(), + YamlConfigHelper.string(categoryMap, "name", categoryEntry.getKey()), + YamlConfigHelper.string(categoryMap, "icon", "minecraft:book"), + shopEntries + )); + } + + return new ShopCatalog(shopUi.title(), shopUi.rows(), shopUi.fillerItemId(), categories); + } + + public Optional category(String key) { + return categories.stream().filter(category -> category.key().equalsIgnoreCase(key)).findFirst(); + } + + public static String defaultYaml() { + return """ + categories: + utility: + name: "Utility" + icon: "minecraft:compass" + items: + diamond_supply: + slot: 10 + icon: "minecraft:diamond" + name: "Diamond Supply" + description: + - "Example item listing with custom amount support." + - "Players can choose how many bundles to buy at once." + cost: 25 + repeatable: true + allow_custom_amount: true + max_custom_amount: 64 + rewards: + - type: item + item: "minecraft:diamond" + amount: 1 + name: "Diamond" + + hunter_pack: + slot: 11 + icon: "minecraft:ender_eye" + name: "Hunter Pack" + description: + - "A quick combat bundle bought with souls." + - "Rewards can mix items and temporary effects." + cost: 150 + repeatable: true + cooldown_seconds: 60 + rewards: + - type: item + item: "minecraft:golden_apple" + amount: 2 + name: "Golden Apple" + - type: effect + effect: "minecraft:speed" + duration_seconds: 180 + amplifier: 0 + stack_mode: ADD_DURATION + name: "Speed Boost" + + unlocks: + name: "Unlocks" + icon: "minecraft:nether_star" + items: + nickname_access: + slot: 13 + icon: "minecraft:name_tag" + name: "Nickname Access" + description: + - "Grants a permanent permission node." + - "Requires LuckPerms for external permissions." + cost: 1000 + repeatable: false + rewards: + - type: permission + node: "example.nick" + value: true + store_fallback: true + name: "Nickname Permission" + + utility_commands: + name: "Command Hooks" + icon: "minecraft:command_block" + items: + starter_crate: + slot: 15 + icon: "minecraft:chest" + name: "Starter Crate" + description: + - "Runs a configurable console command after purchase." + cost: 250 + repeatable: true + allow_custom_amount: false + rewards: + - type: command + command: "say %player% just opened a starter crate purchased with souls." + run_as_console: true + name: "Starter Crate Hook" + """; + } + + private static List parseRewards(List rawRewards) { + List rewards = new ArrayList<>(); + + for (Object rewardValue : rawRewards) { + if (!(rewardValue instanceof Map rawRewardMap)) { + continue; + } + + Map rewardMap = toStringMap(rawRewardMap); + String typeName = YamlConfigHelper.requiredType(rewardMap, "type", "ITEM"); + String rewardName = optionalString(rewardMap, "name", optionalString(rewardMap, "display_name", null)); + RewardType type; + try { + type = RewardType.valueOf(typeName); + } catch (IllegalArgumentException ignored) { + continue; + } + + switch (type) { + case ITEM -> rewards.add(new ItemRewardDefinition( + YamlConfigHelper.string(rewardMap, "item", "minecraft:stone"), + Math.max(1, YamlConfigHelper.intValue(rewardMap, "amount", 1)), + rewardName + )); + case PERMISSION -> rewards.add(new PermissionRewardDefinition( + YamlConfigHelper.string(rewardMap, "node", "soulsteal.example"), + YamlConfigHelper.bool(rewardMap, "value", true), + YamlConfigHelper.bool(rewardMap, "store_fallback", true), + rewardName + )); + case EFFECT -> { + StackMode stackMode; + try { + stackMode = StackMode.valueOf(YamlConfigHelper.string(rewardMap, "stack_mode", StackMode.REPLACE.name()).toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + stackMode = StackMode.REPLACE; + } + + rewards.add(new EffectRewardDefinition( + YamlConfigHelper.string(rewardMap, "effect", "minecraft:speed"), + Math.max(1, YamlConfigHelper.intValue(rewardMap, "duration_seconds", 60)), + Math.max(0, YamlConfigHelper.intValue(rewardMap, "amplifier", 0)), + stackMode, + YamlConfigHelper.bool(rewardMap, "ambient", false), + YamlConfigHelper.bool(rewardMap, "show_particles", true), + YamlConfigHelper.bool(rewardMap, "show_icon", true), + rewardName + )); + } + case COMMAND -> rewards.add(new CommandRewardDefinition( + YamlConfigHelper.string(rewardMap, "command", "say %player% purchased an empty reward."), + YamlConfigHelper.bool(rewardMap, "run_as_console", true), + rewardName + )); + } + } + + return rewards; + } + + private static String optionalString(Map map, String key, String defaultValue) { + String value = YamlConfigHelper.string(map, key, defaultValue == null ? "" : defaultValue).trim(); + return value.isEmpty() ? defaultValue : value; + } + + private static Map toStringMap(Map rawMap) { + Map converted = new LinkedHashMap<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getKey() != null) { + converted.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return converted; + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/ShopCategoryDefinition.java b/src/main/java/com/g2806/soulsteal/shop/ShopCategoryDefinition.java new file mode 100644 index 0000000..2872ed0 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/ShopCategoryDefinition.java @@ -0,0 +1,7 @@ +package com.g2806.soulsteal.shop; + +import java.util.List; + +/** Logical grouping of shop entries, rendered as a separate page within the GUI. */ +public record ShopCategoryDefinition(String key, String name, String iconItemId, List entries) { +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/ShopEntryDefinition.java b/src/main/java/com/g2806/soulsteal/shop/ShopEntryDefinition.java new file mode 100644 index 0000000..5fdface --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/ShopEntryDefinition.java @@ -0,0 +1,19 @@ +package com.g2806.soulsteal.shop; + +import java.util.List; + +/** A single buyable listing displayed in the shop GUI. */ +public record ShopEntryDefinition( + String id, + int slot, + String iconItemId, + String name, + List description, + long cost, + boolean repeatable, + long cooldownSeconds, + boolean allowCustomAmount, + int maxCustomAmount, + List rewards +) { +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java b/src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java new file mode 100644 index 0000000..df36971 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java @@ -0,0 +1,96 @@ +package com.g2806.soulsteal.shop; + +import com.g2806.soulsteal.service.ShopService; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.inventory.SimpleInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.Slot; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; + +/** + * A vanilla generic container screen handler that turns chest clicks into shop actions. + */ +public final class SoulShopScreenHandler extends ScreenHandler { + private final SimpleInventory inventory; + private final ShopService shopService; + private final ShopService.ShopView view; + + public SoulShopScreenHandler(int syncId, PlayerInventory playerInventory, ShopService shopService, ShopService.ShopView view) { + super(typeForRows(view.rows()), syncId); + this.shopService = shopService; + this.view = view; + this.inventory = shopService.createInventory((ServerPlayerEntity) playerInventory.player, view); + + int rows = view.rows(); + for (int row = 0; row < rows; row++) { + for (int column = 0; column < 9; column++) { + int slotIndex = row * 9 + column; + this.addSlot(new ShopSlot(inventory, slotIndex, 8 + column * 18, 18 + row * 18)); + } + } + + int playerInventoryY = 18 + rows * 18 + 14; + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + this.addSlot(new Slot(playerInventory, column + row * 9 + 9, 8 + column * 18, playerInventoryY + row * 18)); + } + } + + int hotbarY = playerInventoryY + 58; + for (int column = 0; column < 9; column++) { + this.addSlot(new Slot(playerInventory, column, 8 + column * 18, hotbarY)); + } + } + + @Override + public ItemStack quickMove(PlayerEntity player, int slot) { + return ItemStack.EMPTY; + } + + @Override + public boolean canUse(PlayerEntity player) { + return true; + } + + @Override + public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) { + if (!(player instanceof ServerPlayerEntity serverPlayer)) { + return; + } + + if (slotIndex >= 0 && slotIndex < inventory.size()) { + shopService.handleClick(serverPlayer, view, slotIndex); + } + } + + private static ScreenHandlerType typeForRows(int rows) { + return switch (rows) { + case 2 -> ScreenHandlerType.GENERIC_9X2; + case 3 -> ScreenHandlerType.GENERIC_9X3; + case 4 -> ScreenHandlerType.GENERIC_9X4; + case 5 -> ScreenHandlerType.GENERIC_9X5; + case 6 -> ScreenHandlerType.GENERIC_9X6; + default -> ScreenHandlerType.GENERIC_9X1; + }; + } + + private static final class ShopSlot extends Slot { + private ShopSlot(SimpleInventory inventory, int index, int x, int y) { + super(inventory, index, x, y); + } + + @Override + public boolean canInsert(ItemStack stack) { + return false; + } + + @Override + public boolean canTakeItems(PlayerEntity playerEntity) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/shop/StackMode.java b/src/main/java/com/g2806/soulsteal/shop/StackMode.java new file mode 100644 index 0000000..d946f4e --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/StackMode.java @@ -0,0 +1,8 @@ +package com.g2806.soulsteal.shop; + +/** Controls how potion-effect rewards combine with an existing active effect. */ +public enum StackMode { + REPLACE, + ADD_DURATION, + MAX_DURATION +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java b/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java new file mode 100644 index 0000000..ae84712 --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java @@ -0,0 +1,36 @@ +package com.g2806.soulsteal.util; + +/** Formats small configuration-driven durations for chat messages and shop tooltips. */ +public final class DurationFormatter { + private DurationFormatter() { + } + + public static String formatSeconds(long totalSeconds) { + if (totalSeconds <= 0L) { + return "0s"; + } + + long days = totalSeconds / 86_400L; + long hours = (totalSeconds % 86_400L) / 3_600L; + long minutes = (totalSeconds % 3_600L) / 60L; + long seconds = totalSeconds % 60L; + StringBuilder builder = new StringBuilder(); + + append(builder, days, "d"); + append(builder, hours, "h"); + append(builder, minutes, "m"); + append(builder, seconds, "s"); + + return builder.toString().trim(); + } + + private static void append(StringBuilder builder, long value, String suffix) { + if (value <= 0L) { + return; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(value).append(suffix); + } +} \ No newline at end of file diff --git a/src/main/java/com/g2806/soulsteal/util/SoulTexts.java b/src/main/java/com/g2806/soulsteal/util/SoulTexts.java new file mode 100644 index 0000000..55f647e --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/util/SoulTexts.java @@ -0,0 +1,36 @@ +package com.g2806.soulsteal.util; + +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +/** Centralized chat text helpers so command and gameplay messaging stay consistent. */ +public final class SoulTexts { + private SoulTexts() { + } + + public static Text info(String message) { + return prefixed(message, Formatting.GRAY); + } + + public static Text success(String message) { + return prefixed(message, Formatting.GREEN); + } + + public static Text warning(String message) { + return prefixed(message, Formatting.GOLD); + } + + public static Text error(String message) { + return prefixed(message, Formatting.RED); + } + + public static MutableText accent(String message) { + return Text.literal(message).formatted(Formatting.AQUA); + } + + private static Text prefixed(String message, Formatting formatting) { + return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA) + .append(Text.literal(message).formatted(formatting)); + } +} \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..f726e0b --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "soulsteal", + "version": "${version}", + "name": "Soul Steal", + "description": "A server-side soul economy mod with bounties, tracking, and a configurable reward shop.", + "authors": [ + "Gabrieli2806" + ], + "contact": { + "homepage": "https://www.fiverr.com/gabriel2806/" + }, + "license": "MIT", + "icon": "assets/soulsteal/icon.png", + "environment": "*", + "entrypoints": { + "main": [ + "com.g2806.soulsteal.SoulStealMod" + ] + }, + "depends": { + "fabricloader": "*", + "minecraft": "~1.21.11", + "java": ">=21", + "fabric-api": "*" + } +} \ No newline at end of file