feat(commands): add admin tracker compass grant command and document it

- add /souls tracker give <player> <target> as an admin-only command
- reuse the existing TrackerCompassService so the command uses the same tracker behavior and duration as kill-granted compasses
- gate the command behind soulsteal.admin
- update README.md command reference
- update docs/PROJECT.md with tracker compass behavior and the new admin command
- keep the implementation scoped to the command layer without changing the tracker service API
This commit is contained in:
darwincereska
2026-05-08 15:42:12 -04:00
parent 859e1bc21f
commit 84e05eff7f
14 changed files with 1044 additions and 351 deletions
+3
View File
@@ -2,6 +2,8 @@
Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bounties, tracker compasses, and a vanilla-compatible soul shop.
Full project documentation is available in [docs/PROJECT.md](docs/PROJECT.md).
## Requirements
| Requirement | Value |
@@ -27,6 +29,7 @@ Players gain souls for killing other players and lose souls whenever they die, w
| `/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 tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. |
| `/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. |
+276
View File
@@ -0,0 +1,276 @@
# Soul Steal Project Documentation
## Overview
Soul Steal is a server-side Fabric mod that implements a soul economy, player bounties, tracker compasses, a vanilla-style shop GUI, optional HUD elements, and permission-backed admin controls.
The mod is built to run entirely on the server. Players do not need a client mod to use the commands or the shop.
## Runtime Architecture
The entry point is [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java), which performs four jobs:
1. Loads configuration from `config/soulsteal/config.yml`.
2. Loads persistent player data from `config/soulsteal/soulsteal-data.json`.
3. Instantiates the feature services.
4. Registers command, tick, join, death, and disconnect handlers.
The main services are:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java): balance reads and mutations.
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java): bounty placement, expiry, and kill claims.
- [`TrackerCompassService`](../src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java): temporary tracking compasses for player kills.
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java): server-side shop GUI and purchases.
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java): validates and grants rewards from shop entries.
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java): permission lookups and fallback storage.
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java): scoreboard sidebar, leaderboard, and bounty bossbar.
Shared utilities:
- [`SoulTexts`](../src/main/java/com/g2806/soulsteal/util/SoulTexts.java): formatted feedback, success, warning, and error messages.
- [`DurationFormatter`](../src/main/java/com/g2806/soulsteal/util/DurationFormatter.java): human-readable duration strings.
## Feature Summary
### Soul Economy
Players have a soul balance stored per UUID.
- Killing another player can grant souls.
- Dying can remove souls using configurable flat and percentage penalties.
- Transfers between players can be enabled or disabled in config.
- Admin commands can set, add, or remove balances directly.
Implemented by:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java)
- [`SoulCommandRegistrar`](../src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java)
### Bounties
Players can place timed bounties on other players using their souls as the payment source.
Behavior:
- Placement is bounded by min/max value, min/max duration, cooldowns, and active bounty limits.
- Killing the target claims all active bounties on that target.
- If a bounty expires, the target receives a configured survivor payout.
- Bounties are cleared if the target dies to non-player damage and no killer claims them.
Implemented by:
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java)
- [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java)
### Tracker Compasses
When enabled, killing a player grants a temporary tracker compass.
Behavior:
- The compass tracks the killed player using lodestone-tracker data.
- The item stores target UUID, target name, and expiration time in custom NBT.
- Expired compasses are removed during server ticks.
- Optional behavior can remove the compass if the target is offline.
Admins can also grant a tracker compass directly with `/souls tracker give <player> <target>`. This uses the same tracker data and duration as the kill-granted version.
Implemented by:
- [`TrackerCompassService`](../src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java)
### Shop
The shop is a chest-based GUI rendered entirely with vanilla screen handler APIs.
Behavior:
- The home view lists categories with pagination.
- Category views list entries with pagination.
- Item entries can be direct purchases or quantity-select purchases.
- Purchases can be repeatable or single-unlock.
- Cooldowns can be stored per entry per player.
- Rewards can include items, effects, commands, and permissions.
Implemented by:
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java)
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java)
- [`SoulShopScreenHandler`](../src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java)
- [`ShopCatalog`](../src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java)
### HUD
The HUD layer is optional and configurable per player.
Behavior:
- Scoreboard sidebar can be enabled globally and toggled per player.
- Leaderboard pages are built from stored player names and balance values.
- Wanted-player bossbars show bounty value and remaining time.
Implemented by:
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java)
### Permissions
The mod checks permissions in two layers:
1. External permission backends through the Fabric permissions API and optional LuckPerms integration.
2. Internal fallback storage persisted in `soulsteal-data.json`.
This allows reward-granted permissions and admin nodes to keep working even without an external permission mod.
Implemented by:
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java)
## Command Reference
The root command has two aliases:
- `/souls`
- `/soul`
### Player Commands
- `/souls`
- Shows your current balance.
- `/souls balance`
- Same as `/souls`.
- `/souls balance <player>`
- Shows another players balance.
- `/souls pay <player> <amount>`
- Transfers souls to another player.
- `/souls shop [category]`
- Opens the shop GUI.
- `/souls bounty place <player> <amount> [durationSeconds]`
- Places a bounty.
- `/souls bounty list [player]`
- Lists active bounties.
- `/souls scoreboard`
- Shows scoreboard visibility state.
- `/souls scoreboard toggle`
- Toggles the players scoreboard preference.
- `/souls scoreboard on`
- Forces scoreboard visibility on.
- `/souls scoreboard off`
- Forces scoreboard visibility off.
- `/souls top [page]`
- Shows leaderboard pages.
- `/souls tracker give <player> <target>`
- Gives a tracker compass to one player that points at another player.
### Admin Commands
- `/souls reload`
- Reloads config and shop definitions.
- `/souls set <player> <amount>`
- Sets a balance directly.
- `/souls add <player> <amount>`
- Adds souls to a balance.
- `/souls take <player> <amount>`
- Removes souls from a balance.
Permission defaults and node names are configured in `config.yml`.
## Configuration Files
### `config/soulsteal/config.yml`
Primary configuration file. It controls:
- Economy values
- Death penalties
- Transfer rules
- Bounty limits and payouts
- Tracker compass behavior
- Shop UI defaults
- HUD toggles and titles
- Permission node names
The schema is defined in [`SoulStealConfig`](../src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java).
### `config/soulsteal/shop.yml`
Shop catalog definition. It controls:
- Shop title and layout
- Category list
- Entry pricing
- Entry cooldowns
- Reward definitions
- Optional custom quantity selector behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/soulsteal-data.json`
Persistent runtime data. It stores:
- Player soul balances
- Active bounties
- Bounty placement cooldowns
- Shop unlocks
- Shop purchase cooldowns
- Permission fallback grants
- Player name history
- Scoreboard visibility preferences
Loaded and saved by [`SoulStealDataStore`](../src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java).
## Data Flow
### Player Join
When a player joins:
1. Their name is recorded in persistent data.
2. Their HUD state is refreshed.
3. Any configured scoreboard or bossbar state is pushed to them.
### Player Kill
When one player kills another player:
1. The killer receives kill reward souls if enabled.
2. Any matching bounties on the victim are claimed.
3. The killer receives a tracker compass if the tracker feature is enabled.
### Player Death
When a player dies:
1. The configured death penalty is applied.
2. If the death was not caused by another player, unclaimed bounties on the victim can be cleared.
### Server Tick
On server tick:
1. Tracker compasses are updated and expired compasses are removed.
2. Bounty expirations are processed once per second.
3. HUD elements are refreshed.
### Server Shutdown
On shutdown:
- Persistent data is saved to disk.
## Project Structure
- `src/main/java/com/g2806/soulsteal/`
- Bootstrap, commands, services, config, data, shop, and utilities.
- `src/main/resources/`
- Fabric metadata and resource files.
- `build.gradle`
- Fabric Loom build configuration.
## Development Notes
- The mod targets Java 21.
- It is structured as a server-only feature set, so most behavior is driven by server lifecycle events.
- Persistence is intentionally simple: one JSON data file and YAML-driven configuration.
- External integrations are reflected through reflection-based checks so the mod can run without hard dependencies on permission backends.
@@ -51,6 +51,10 @@ public final class SoulStealMod implements ModInitializer {
private ShopService shopService;
private HudService hudService;
/**
* Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers.
*/
@Override
public void onInitialize() {
LOGGER.info("Initializing Soul Steal");
@@ -140,6 +144,12 @@ public final class SoulStealMod implements ModInitializer {
hudService.tick(server, now);
}
/**
* Reloads the YAML configuration bundle from disk.
*
* @return {@code true} if the reload succeeded; {@code false} if the config file could not be
* loaded
*/
public boolean reloadConfiguration() {
try {
configBundle = ConfigBundle.load(configDirectory);
@@ -158,38 +168,83 @@ public final class SoulStealMod implements ModInitializer {
}
}
/**
* Returns the loaded configuration bundle.
*
* @return the current configuration bundle
*/
public ConfigBundle bundle() {
return configBundle;
}
/**
* Returns the active mod configuration.
*
* @return the current configuration tree
*/
public SoulStealConfig config() {
return configBundle.config();
}
/**
* Returns the service responsible for balance changes.
*
* @return the soul service
*/
public SoulService soulService() {
return soulService;
}
/**
* Returns the permission service.
*
* @return the permission service
*/
public PermissionService permissionService() {
return permissionService;
}
/**
* Returns the bounty service.
*
* @return the bounty service
*/
public BountyService bountyService() {
return bountyService;
}
/**
* Returns the reward service.
*
* @return the reward service
*/
public RewardService rewardService() {
return rewardService;
}
/**
* Returns the tracker compass service.
*
* @return the tracker compass service
*/
public TrackerCompassService trackerCompassService() {
return trackerCompassService;
}
/**
* Returns the shop service.
*
* @return the shop service
*/
public ShopService shopService() {
return shopService;
}
/**
* Returns the HUD service.
*
* @return the HUD service
*/
public HudService hudService() {
return hudService;
}
@@ -25,36 +25,57 @@ public final class SoulCommandRegistrar {
private SoulCommandRegistrar() {
}
/**
* Registers the public command roots exposed by the mod.
*
* @param dispatcher Brigadier dispatcher used by Fabric to install commands
* @param mod active mod instance used to resolve services and configuration
*/
public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) {
// Register both command roots so players can use either the full name or the short alias.
dispatcher.register(buildRoot("souls", mod));
dispatcher.register(buildRoot("soul", mod));
}
/**
* Builds one of the root command aliases and all nested subcommands.
*
* @param rootName literal command root to register, such as {@code souls} or {@code soul}
* @param mod active mod instance used to resolve services and permissions
* @return a fully populated command tree for the requested root
*/
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) {
return literal(rootName)
// Running the root command alone shows the player's own balance.
.executes(context -> showOwnBalance(context, mod))
// /soul balance
.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()))
// Only privileged sources can inspect another player's balance.
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().balanceOthersNode()))
.executes(context -> showTargetBalance(context, mod))))
// /soul pay <player> <amount>
.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)))))
// /soul shop [category]
.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")))))
// /soul bounty ...
.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))
// Default duration comes from config unless the caller provides one explicitly.
.executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds()))
.then(argument("durationSeconds", LongArgumentType.longArg(1L))
.executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds")))))))
@@ -62,25 +83,38 @@ public final class SoulCommandRegistrar {
.executes(context -> listBounties(context, mod, null))
.then(argument("player", EntityArgumentType.player())
.executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player"))))))
.then(literal("scoreboard")
// /soul scoreboard ...
.then(literal("scoreboard")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode()))
.executes(context -> showScoreboardStatus(context, mod))
// Toggle uses the player's stored preference.
.then(literal("toggle")
.executes(context -> toggleScoreboard(context, mod)))
.executes(context -> toggleScoreboard(context, mod)))
// Explicit on/off commands are useful for scripts and exact control.
.then(literal("on")
.executes(context -> setScoreboardVisibility(context, mod, true)))
.executes(context -> setScoreboardVisibility(context, mod, true)))
.then(literal("off")
.executes(context -> setScoreboardVisibility(context, mod, false))))
.then(literal("top")
.executes(context -> setScoreboardVisibility(context, mod, false))))
// /soul top [page]
.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")))))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page")))))
// /soul tracker give <player> <target>
.then(literal("tracker")
.requires(source -> mod.permissionService().hasAny(source, 2, mod.config().permissions().adminNode()))
.then(literal("give")
.then(argument("player", EntityArgumentType.player())
.then(argument("target", EntityArgumentType.player())
.executes(context -> giveTrackerCompass(context, mod))))))
// Admin-only maintenance and balance editing commands follow.
.then(literal("reload")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().reloadNode()))
.executes(context -> reload(context, mod)))
// Set replaces the target balance outright.
.then(literal("set")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
@@ -88,6 +122,7 @@ public final class SoulCommandRegistrar {
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(0L))
.executes(context -> setBalance(context, mod)))))
// Add and take are bounded changes; they keep the balance moving up or down.
.then(literal("add")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
@@ -147,6 +182,7 @@ public final class SoulCommandRegistrar {
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.";
// This is a player preference; config can still disable the HUD globally.
if (!mod.config().hud().scoreboard().enabled()) {
message += " The server-wide HUD toggle is disabled in config.";
}
@@ -190,12 +226,30 @@ public final class SoulCommandRegistrar {
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);
// Convert the page-local index into the stable 1-based rank shown to players.
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;
}
/**
* Gives a tracker compass to one player that points at another player.
*
* @param context command invocation context
* @param mod active mod instance
* @return command result code
* @throws com.mojang.brigadier.exceptions.CommandSyntaxException if either player argument cannot be resolved
*/
private static int giveTrackerCompass(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = EntityArgumentType.getPlayer(context, "player");
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "target");
mod.trackerCompassService().giveTrackerCompass(player, target);
context.getSource().sendFeedback(() -> SoulTexts.success("Gave a tracker compass to " + player.getName().getString() + " for " + target.getName().getString() + "."), true);
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");
@@ -230,6 +284,7 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false);
for (StoredBounty bounty : bounties) {
// Round up so a bounty that expires in a fraction of a second still reports 1 second remaining.
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);
}
@@ -51,34 +51,74 @@ public final class SoulStealData {
return this;
}
/**
* Returns the persistent soul balance table keyed by player UUID string.
*
* @return mutable soul balance map
*/
public Map<String, Long> souls() {
return souls;
}
/**
* Returns the active bounty list.
*
* @return mutable list of active bounties
*/
public List<StoredBounty> activeBounties() {
return activeBounties;
}
/**
* Returns the set of shop entries unlocked per player.
*
* @return mutable unlock table
*/
public Map<String, Set<String>> unlockedEntries() {
return unlockedEntries;
}
/**
* Returns the per-entry purchase cooldown table.
*
* @return mutable cooldown map
*/
public Map<String, Map<String, Long>> purchaseCooldowns() {
return purchaseCooldowns;
}
/**
* Returns the internal permission fallback table.
*
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
/**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns;
}
/**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames;
}
/**
* Returns the per-player scoreboard visibility table.
*
* @return mutable scoreboard visibility map
*/
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility;
}
@@ -29,6 +29,11 @@ public final class SoulStealDataStore {
this.dataFile = dataDirectory.resolve("soulsteal-data.json");
}
/**
* Loads persistent state from disk, creating a new file if needed.
*
* @throws IOException if the file cannot be read or created
*/
public synchronized void load() throws IOException {
Files.createDirectories(dataDirectory);
if (Files.notExists(dataFile)) {
@@ -43,10 +48,20 @@ public final class SoulStealDataStore {
}
}
/**
* Returns the in-memory persistent data snapshot.
*
* @return mutable data model used by the running server
*/
public synchronized SoulStealData data() {
return data;
}
/**
* Saves the current in-memory state to disk atomically when possible.
*
* @throws IOException if the file cannot be written
*/
public synchronized void save() throws IOException {
Files.createDirectories(dataDirectory);
Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp");
@@ -18,14 +18,29 @@ public record StoredBounty(
long createdAtEpochMillis,
long expiresAtEpochMillis
) {
/**
* Parses the bounty id as a UUID.
*
* @return bounty UUID
*/
public UUID idAsUuid() {
return UUID.fromString(id);
}
/**
* Parses the placer id as a UUID.
*
* @return placer UUID
*/
public UUID placerUuidAsUuid() {
return UUID.fromString(placerUuid);
}
/**
* Parses the target id as a UUID.
*
* @return target UUID
*/
public UUID targetUuidAsUuid() {
return UUID.fromString(targetUuid);
}
@@ -24,6 +24,18 @@ public final class BountyService {
this.soulService = soulService;
}
/**
* Attempts to place a bounty on a target player.
*
* @param placerUuid player paying for the bounty
* @param placerName display name used for messages
* @param targetUuid target player UUID
* @param targetName target display name used for messages
* @param amount bounty value in souls
* @param durationSeconds bounty lifetime in seconds
* @param nowEpochMillis current time used for cooldown and expiry calculations
* @return placement outcome and the created bounty when successful
*/
public PlaceBountyResult placeBounty(
UUID placerUuid,
String placerName,
@@ -86,6 +98,13 @@ public final class BountyService {
return new PlaceBountyResult(true, "Bounty placed successfully.", bounty);
}
/**
* Claims all active bounties on a target after a successful kill.
*
* @param killerUuid player receiving the payout
* @param targetUuid target player whose bounties may be claimed
* @return the combined payout and the list of claimed bounties
*/
public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) {
List<StoredBounty> claimed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -110,6 +129,12 @@ public final class BountyService {
return new ClaimBountyResult(reward, claimed);
}
/**
* Clears all active bounties on a target without paying them out.
*
* @param targetUuid target player UUID
* @return the removed bounty records
*/
public List<StoredBounty> clearForTarget(UUID targetUuid) {
List<StoredBounty> removed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -132,6 +157,12 @@ public final class BountyService {
return removed;
}
/**
* Processes expired bounties and pays the configured survivor reward where applicable.
*
* @param nowEpochMillis current time used to determine expiration
* @return payout records for every expired bounty handled in this pass
*/
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
List<ExpiredBountyPayout> payouts = new ArrayList<>();
@@ -157,15 +188,32 @@ public final class BountyService {
return payouts;
}
/**
* Returns a snapshot of all active bounties.
*
* @return immutable copy of the current active bounty list
*/
public List<StoredBounty> activeBounties() {
return List.copyOf(dataStore.data().activeBounties());
}
/**
* Returns all active bounties for a specific target player.
*
* @param targetUuid target player UUID
* @return bounties currently assigned to that player
*/
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) {
String targetKey = key(targetUuid);
return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList();
}
/**
* Returns the next time the given placer may create another bounty.
*
* @param placerUuid player UUID to inspect
* @return epoch milliseconds when the placement cooldown ends, or {@code 0} if none exists
*/
public long nextPlacementTime(UUID placerUuid) {
return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L);
}
@@ -53,16 +53,32 @@ public final class HudService {
this.bountyService = bountyService;
}
/**
* Records player metadata and refreshes their HUD state when they join.
*
* @param player joining player
*/
public void handlePlayerJoin(ServerPlayerEntity player) {
rememberPlayer(player);
refreshPlayerDisplays(player, System.currentTimeMillis());
}
/**
* Clears any per-player HUD state when a player disconnects.
*
* @param player disconnecting player
*/
public void handlePlayerDisconnect(ServerPlayerEntity player) {
clearSidebar(player);
clearBossBar(player);
}
/**
* Refreshes active HUD elements for all online players and trims stale state.
*
* @param server current server instance
* @param nowEpochMillis current time used for countdown calculations
*/
public void tick(MinecraftServer server, long nowEpochMillis) {
Set<UUID> onlinePlayers = new HashSet<>();
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
@@ -81,11 +97,24 @@ public final class HudService {
});
}
/**
* Returns whether the given player's scoreboard sidebar is currently visible.
*
* @param playerUuid player UUID to inspect
* @return current visibility state, falling back to the config default
*/
public boolean isScoreboardVisible(UUID playerUuid) {
return dataStore.data().scoreboardVisibility()
.getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible());
}
/**
* Sets scoreboard visibility for a player and refreshes their sidebar immediately.
*
* @param player player to update
* @param visible requested visibility state
* @return the stored visibility state
*/
public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) {
rememberPlayer(player);
Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible);
@@ -96,10 +125,22 @@ public final class HudService {
return visible;
}
/**
* Flips the scoreboard visibility flag for a player.
*
* @param player player to update
* @return the updated visibility state
*/
public boolean toggleScoreboardVisible(ServerPlayerEntity player) {
return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid()));
}
/**
* Builds a leaderboard page from stored names and balances.
*
* @param requestedPage 1-based page number requested by the caller
* @return the clamped leaderboard page and its entries
*/
public LeaderboardPage leaderboard(int requestedPage) {
Set<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
playerKeys.addAll(dataStore.data().souls().keySet());
@@ -26,6 +26,14 @@ public final class PermissionService {
this.dataStore = dataStore;
}
/**
* Checks whether a command source has a permission node.
*
* @param source permission subject
* @param permission node to check
* @param defaultLevel fallback operator level when no permission backend is available
* @return {@code true} if the source is allowed to use the permission
*/
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) {
return true;
@@ -39,6 +47,14 @@ public final class PermissionService {
return source.getPlayer() == null || defaultLevel <= 0;
}
/**
* Checks whether a source has any permission from the provided set.
*
* @param source permission subject
* @param defaultLevel fallback operator level when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) {
@@ -48,6 +64,14 @@ public final class PermissionService {
return false;
}
/**
* Checks whether a player has a permission node.
*
* @param player permission subject
* @param permission node to check
* @param defaultValue fallback value when no permission backend is available
* @return {@code true} if the player is allowed to use the permission
*/
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) {
if (hasStoredPermission(player.getUuid(), permission)) {
return true;
@@ -61,6 +85,14 @@ public final class PermissionService {
return defaultValue;
}
/**
* Checks whether a player has any permission from the provided set.
*
* @param player permission subject
* @param defaultValue fallback value when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) {
@@ -70,6 +102,15 @@ public final class PermissionService {
return false;
}
/**
* Grants a permission through LuckPerms if available and optionally stores a fallback copy.
*
* @param playerUuid player receiving the permission
* @param permission node to grant or revoke
* @param value desired node value
* @param storeFallback whether to persist the value in Soul Steal's internal store
* @return grant outcome and backend details
*/
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false;
@@ -61,6 +61,13 @@ public final class ShopService {
this.dataStore = dataStore;
}
/**
* Opens the shop UI for either the home view or a specific category.
*
* @param player player to show the UI to
* @param requestedCategoryKey category key to open, or {@code null} for the home view
* @param requestedPage zero-based page index to display
*/
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) {
if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) {
openView(player, resolveHomeView(requestedPage));
@@ -70,6 +77,13 @@ public final class ShopService {
openView(player, resolveCategoryView(requestedCategoryKey, requestedPage));
}
/**
* Builds the backing inventory for a specific shop view.
*
* @param player player who will interact with the inventory
* @param view resolved shop view state
* @return inventory contents appropriate for the supplied view
*/
public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
@@ -78,6 +92,13 @@ public final class ShopService {
};
}
/**
* Dispatches a click inside the shop GUI to the correct view handler.
*
* @param player player interacting with the shop
* @param view current resolved view
* @param slotIndex clicked slot index
*/
public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) {
switch (view) {
case HomeView homeView -> handleHomeClick(player, homeView, slotIndex);
@@ -18,15 +18,35 @@ public final class SoulService {
this.dataStore = dataStore;
}
/**
* Looks up the stored balance for a player UUID.
*
* @param playerUuid player identifier to inspect
* @return the current balance, or the configured starting balance for new players
*/
public long balanceOf(UUID playerUuid) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls());
}
/**
* Checks whether a player currently has at least the requested amount of souls.
*
* @param playerUuid player identifier to inspect
* @param amount amount to compare against
* @return {@code true} when the player has enough souls
*/
public boolean hasSouls(UUID playerUuid, long amount) {
return balanceOf(playerUuid) >= Math.max(0L, amount);
}
/**
* Adds souls to a player's balance and clamps the result to the configured maximum.
*
* @param playerUuid player identifier to update
* @param amount amount to add
* @return the updated balance after clamping
*/
public long addSouls(UUID playerUuid, long amount) {
if (amount <= 0L) {
return balanceOf(playerUuid);
@@ -39,6 +59,13 @@ public final class SoulService {
return updated;
}
/**
* Removes souls from a player's balance and clamps the result at zero.
*
* @param playerUuid player identifier to update
* @param amount amount to remove
* @return the updated balance after clamping
*/
public long removeSouls(UUID playerUuid, long amount) {
if (amount <= 0L) {
return balanceOf(playerUuid);
@@ -50,11 +77,23 @@ public final class SoulService {
return updated;
}
/**
* Sets a player's balance directly, applying the configured upper bound.
*
* @param playerUuid player identifier to update
* @param amount requested new balance
*/
public void setSouls(UUID playerUuid, long amount) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount)));
}
/**
* Applies the configured death penalty to a player.
*
* @param playerUuid player identifier to penalize
* @return the amount removed and the new balance after applying the penalty
*/
public SoulChange applyDeathPenalty(UUID playerUuid) {
long current = balanceOf(playerUuid);
if (current <= 0L) {
@@ -73,6 +112,14 @@ public final class SoulService {
return new SoulChange(-boundedLoss, newBalance);
}
/**
* Transfers souls between two players if the transfer rules allow it.
*
* @param senderUuid source player UUID
* @param receiverUuid destination player UUID
* @param amount amount to transfer
* @return the transfer result, including balances and a human-readable message
*/
public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) {
SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer();
if (!transferConfig.enabled()) {
@@ -5,6 +5,12 @@ public final class DurationFormatter {
private DurationFormatter() {
}
/**
* Formats a duration in seconds into a compact human-readable string.
*
* @param totalSeconds duration to format
* @return formatted duration such as {@code 2h 5m 10s}
*/
public static String formatSeconds(long totalSeconds) {
if (totalSeconds <= 0L) {
return "0s";
@@ -9,22 +9,52 @@ public final class SoulTexts {
private SoulTexts() {
}
/**
* Builds an informational chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text info(String message) {
return prefixed(message, Formatting.GRAY);
}
/**
* Builds a success chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text success(String message) {
return prefixed(message, Formatting.GREEN);
}
/**
* Builds a warning chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text warning(String message) {
return prefixed(message, Formatting.GOLD);
}
/**
* Builds an error chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text error(String message) {
return prefixed(message, Formatting.RED);
}
/**
* Builds highlighted accent text without the standard prefix.
*
* @param message text to accent
* @return formatted text component
*/
public static MutableText accent(String message) {
return Text.literal(message).formatted(Formatting.AQUA);
}