first init
This commit is contained in:
+37
@@ -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
|
||||
@@ -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 <player>` | Admins / `soulsteal.admin` or `soulsteal.admin.balance.others` | Views another player's soul balance. |
|
||||
| `/souls pay <player> <amount>` | 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 <player> <amount> [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 <player> <amount>` | 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. |
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -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
|
||||
@@ -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" "$@"
|
||||
+82
@@ -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%
|
||||
@@ -0,0 +1,11 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven {
|
||||
url = 'https://maven.fabricmc.net/'
|
||||
}
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'Soul Steal'
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<com.g2806.soulsteal.data.StoredBounty> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<ServerCommandSource> dispatcher, SoulStealMod mod) {
|
||||
dispatcher.register(buildRoot("souls", mod));
|
||||
dispatcher.register(buildRoot("soul", mod));
|
||||
}
|
||||
|
||||
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> context, SoulStealMod mod, ServerPlayerEntity target) {
|
||||
java.util.List<StoredBounty> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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<ServerCommandSource> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml());
|
||||
SoulStealConfig config = SoulStealConfig.fromMap(configMap);
|
||||
|
||||
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
|
||||
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
|
||||
|
||||
return new ConfigBundle(config, shopCatalog);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> root) {
|
||||
Map<String, Object> economySection = YamlConfigHelper.section(root, "economy");
|
||||
Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty");
|
||||
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
|
||||
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
|
||||
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker");
|
||||
Map<String, Object> shopSection = YamlConfigHelper.section(root, "shop");
|
||||
Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud");
|
||||
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
|
||||
Map<String, Object> bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar");
|
||||
Map<String, Object> leaderboardSection = YamlConfigHelper.section(hudSection, "leaderboard");
|
||||
Map<String, Object> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<String, Object> 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<String, Object> section(Map<String, Object> 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<Object> list(Map<String, Object> 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<String, Object> root, String key, String defaultValue) {
|
||||
Object value = root.get(key);
|
||||
return value == null ? defaultValue : String.valueOf(value);
|
||||
}
|
||||
|
||||
public static boolean bool(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> stringList(Map<String, Object> root, String key) {
|
||||
List<Object> rawList = list(root, key);
|
||||
List<String> result = new ArrayList<>(rawList.size());
|
||||
for (Object entry : rawList) {
|
||||
if (entry != null) {
|
||||
result.add(String.valueOf(entry));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String requiredType(Map<String, Object> root, String key, String defaultType) {
|
||||
return string(root, key, defaultType).trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Map<String, Object> map(Map<?, ?> rawMap) {
|
||||
Map<String, Object> converted = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
converted.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The structure intentionally stays JSON-friendly so server owners can inspect or recover the
|
||||
* file by hand if needed.</p>
|
||||
*/
|
||||
public final class SoulStealData {
|
||||
private Map<String, Long> souls = new HashMap<>();
|
||||
private List<StoredBounty> activeBounties = new ArrayList<>();
|
||||
private Map<String, Set<String>> unlockedEntries = new HashMap<>();
|
||||
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
|
||||
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
|
||||
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
|
||||
private Map<String, String> playerNames = new HashMap<>();
|
||||
private Map<String, Boolean> 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<String, Long> souls() {
|
||||
return souls;
|
||||
}
|
||||
|
||||
public List<StoredBounty> activeBounties() {
|
||||
return activeBounties;
|
||||
}
|
||||
|
||||
public Map<String, Set<String>> unlockedEntries() {
|
||||
return unlockedEntries;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, Long>> purchaseCooldowns() {
|
||||
return purchaseCooldowns;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, Boolean>> grantedPermissions() {
|
||||
return grantedPermissions;
|
||||
}
|
||||
|
||||
public Map<String, Long> bountyPlacementCooldowns() {
|
||||
return bountyPlacementCooldowns;
|
||||
}
|
||||
|
||||
public Map<String, String> playerNames() {
|
||||
return playerNames;
|
||||
}
|
||||
|
||||
public Map<String, Boolean> scoreboardVisibility() {
|
||||
return scoreboardVisibility;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>All write operations use a temp file and replace step to avoid leaving partial JSON behind on
|
||||
* crash or forced shutdown.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.g2806.soulsteal.data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Persistent bounty entry stored on disk.
|
||||
*
|
||||
* <p>Names are duplicated alongside UUIDs so chat messages stay readable even when one of the
|
||||
* players is offline during resolution.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SoulStealConfig> configSupplier;
|
||||
private final SoulStealDataStore dataStore;
|
||||
private final SoulService soulService;
|
||||
|
||||
public BountyService(Supplier<SoulStealConfig> 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<StoredBounty> claimed = new ArrayList<>();
|
||||
Iterator<StoredBounty> 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<StoredBounty> clearForTarget(UUID targetUuid) {
|
||||
List<StoredBounty> removed = new ArrayList<>();
|
||||
Iterator<StoredBounty> 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<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
|
||||
SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
|
||||
List<ExpiredBountyPayout> payouts = new ArrayList<>();
|
||||
Iterator<StoredBounty> 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<StoredBounty> activeBounties() {
|
||||
return List.copyOf(dataStore.data().activeBounties());
|
||||
}
|
||||
|
||||
public List<StoredBounty> 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<StoredBounty> claimedBounties) {
|
||||
public boolean claimedAny() {
|
||||
return reward > 0L && !claimedBounties.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record ExpiredBountyPayout(StoredBounty bounty, long reward) {
|
||||
}
|
||||
}
|
||||
@@ -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<SoulStealConfig> configSupplier;
|
||||
private final SoulStealDataStore dataStore;
|
||||
private final SoulService soulService;
|
||||
private final BountyService bountyService;
|
||||
private final Map<UUID, SidebarState> sidebars = new HashMap<>();
|
||||
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
|
||||
|
||||
public HudService(
|
||||
Supplier<SoulStealConfig> 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<UUID> 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<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
|
||||
playerKeys.addAll(dataStore.data().souls().keySet());
|
||||
|
||||
List<LeaderboardEntry> 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<Text> lines = buildSidebarLines(player, nowEpochMillis);
|
||||
List<String> 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<StoredBounty> 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<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
|
||||
List<StoredBounty> 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<String> holders) {
|
||||
}
|
||||
|
||||
public record LeaderboardEntry(String playerName, long souls) {
|
||||
}
|
||||
|
||||
public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<String, Boolean> 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<Object> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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<RewardDefinition> 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<RewardDefinition> rewards) {
|
||||
List<String> 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<StatusEffect> 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<Text> describeRewards(List<RewardDefinition> rewards) {
|
||||
List<Text> 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<RewardDefinition> rewards) {
|
||||
return !rewards.isEmpty() && rewards.stream().allMatch(ItemRewardDefinition.class::isInstance);
|
||||
}
|
||||
|
||||
public List<RewardDefinition> scaleItemRewards(List<RewardDefinition> rewards, int multiplier) {
|
||||
if (multiplier <= 1) {
|
||||
return rewards;
|
||||
}
|
||||
|
||||
List<RewardDefinition> 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<Text> 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<StatusEffect> 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<StatusEffect> 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<String> grantedRewards) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The GUI uses vanilla generic container screen types so players do not need any client-side
|
||||
* installation.</p>
|
||||
*/
|
||||
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<ConfigBundle> bundleSupplier;
|
||||
private final SoulService soulService;
|
||||
private final RewardService rewardService;
|
||||
private final SoulStealDataStore dataStore;
|
||||
|
||||
public ShopService(
|
||||
Supplier<ConfigBundle> 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<RewardDefinition> 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<ShopCategoryDefinition> 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<ShopCategoryDefinition> 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<ShopEntryDefinition> 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<ShopEntryDefinition> 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<Text> 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<Text> 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<Text> 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<Text> 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<ShopCategoryDefinition> 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<ShopEntryDefinition> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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<SoulStealConfig> configSupplier;
|
||||
private final SoulStealDataStore dataStore;
|
||||
|
||||
public SoulService(Supplier<SoulStealConfig> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Tracker metadata is stored on the compass item itself so the item remains functional across
|
||||
* server restarts without needing a second persistence table.</p>
|
||||
*/
|
||||
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<SoulStealConfig> configSupplier;
|
||||
private int tickCounter;
|
||||
|
||||
public TrackerCompassService(Supplier<SoulStealConfig> 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<TrackerState> 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<TrackerState> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ShopCategoryDefinition> categories) {
|
||||
public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi) {
|
||||
Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories");
|
||||
List<ShopCategoryDefinition> categories = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, Object> categoryEntry : categoriesSection.entrySet()) {
|
||||
if (!(categoryEntry.getValue() instanceof Map<?, ?> rawCategoryMap)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> categoryMap = toStringMap(rawCategoryMap);
|
||||
Map<String, Object> itemsSection = YamlConfigHelper.section(categoryMap, "items");
|
||||
List<ShopEntryDefinition> shopEntries = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, Object> itemEntry : itemsSection.entrySet()) {
|
||||
if (!(itemEntry.getValue() instanceof Map<?, ?> rawItemMap)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> itemMap = toStringMap(rawItemMap);
|
||||
List<RewardDefinition> 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<ShopCategoryDefinition> 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<RewardDefinition> parseRewards(List<Object> rawRewards) {
|
||||
List<RewardDefinition> rewards = new ArrayList<>();
|
||||
|
||||
for (Object rewardValue : rawRewards) {
|
||||
if (!(rewardValue instanceof Map<?, ?> rawRewardMap)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> map, String key, String defaultValue) {
|
||||
String value = YamlConfigHelper.string(map, key, defaultValue == null ? "" : defaultValue).trim();
|
||||
return value.isEmpty() ? defaultValue : value;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
|
||||
Map<String, Object> converted = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
converted.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
@@ -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<ShopEntryDefinition> entries) {
|
||||
}
|
||||
@@ -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<String> description,
|
||||
long cost,
|
||||
boolean repeatable,
|
||||
long cooldownSeconds,
|
||||
boolean allowCustomAmount,
|
||||
int maxCustomAmount,
|
||||
List<RewardDefinition> rewards
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user