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
+11 -8
View File
@@ -1,6 +1,8 @@
# Soul Steal # 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. 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 ## Requirements
@@ -25,10 +27,11 @@ Players gain souls for killing other players and lose souls whenever they die, w
| `/souls shop [category]` | All players | Opens the soul shop GUI, optionally on a specific category. | | `/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 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 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 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 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 tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. |
| `/souls set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | | `/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 ## Configuration
@@ -36,4 +39,4 @@ Players gain souls for killing other players and lose souls whenever they die, w
| --- | --- | | --- | --- |
| `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. | | `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/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. | | `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. |
+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,8 +51,12 @@ public final class SoulStealMod implements ModInitializer {
private ShopService shopService; private ShopService shopService;
private HudService hudService; private HudService hudService;
@Override /**
public void onInitialize() { * Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers.
*/
@Override
public void onInitialize() {
LOGGER.info("Initializing Soul Steal"); LOGGER.info("Initializing Soul Steal");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID); configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
@@ -138,9 +142,15 @@ public final class SoulStealMod implements ModInitializer {
} }
hudService.tick(server, now); hudService.tick(server, now);
} }
public boolean reloadConfiguration() { /**
* 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 { try {
configBundle = ConfigBundle.load(configDirectory); configBundle = ConfigBundle.load(configDirectory);
return true; return true;
@@ -156,41 +166,86 @@ public final class SoulStealMod implements ModInitializer {
} catch (IOException exception) { } catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception); LOGGER.error("Failed to save Soul Steal data.", exception);
} }
} }
public ConfigBundle bundle() { /**
return configBundle; * Returns the loaded configuration bundle.
} *
* @return the current configuration bundle
public SoulStealConfig config() { */
return configBundle.config(); public ConfigBundle bundle() {
} return configBundle;
}
public SoulService soulService() {
return soulService; /**
} * Returns the active mod configuration.
*
public PermissionService permissionService() { * @return the current configuration tree
return permissionService; */
} public SoulStealConfig config() {
return configBundle.config();
public BountyService bountyService() { }
return bountyService;
} /**
* Returns the service responsible for balance changes.
public RewardService rewardService() { *
return rewardService; * @return the soul service
} */
public SoulService soulService() {
public TrackerCompassService trackerCompassService() { return soulService;
return trackerCompassService; }
}
/**
public ShopService shopService() { * Returns the permission service.
return shopService; *
} * @return the permission service
*/
public HudService hudService() { public PermissionService permissionService() {
return hudService; 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;
}
}
@@ -22,84 +22,119 @@ import static net.minecraft.server.command.CommandManager.literal;
/** Registers the public command surface for Soul Steal. */ /** Registers the public command surface for Soul Steal. */
public final class SoulCommandRegistrar { public final class SoulCommandRegistrar {
private SoulCommandRegistrar() { private SoulCommandRegistrar() {
} }
public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) { /**
dispatcher.register(buildRoot("souls", mod)); * Registers the public command roots exposed by the mod.
dispatcher.register(buildRoot("soul", mod)); *
} * @param dispatcher Brigadier dispatcher used by Fabric to install commands
* @param mod active mod instance used to resolve services and configuration
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) { */
return literal(rootName) public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) {
.executes(context -> showOwnBalance(context, mod)) // Register both command roots so players can use either the full name or the short alias.
.then(literal("balance") dispatcher.register(buildRoot("souls", mod));
.executes(context -> showOwnBalance(context, mod)) dispatcher.register(buildRoot("soul", mod));
.then(argument("player", EntityArgumentType.player()) }
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(), /**
mod.config().permissions().balanceOthersNode())) * Builds one of the root command aliases and all nested subcommands.
.executes(context -> showTargetBalance(context, mod)))) *
.then(literal("pay") * @param rootName literal command root to register, such as {@code souls} or {@code soul}
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) * @param mod active mod instance used to resolve services and permissions
.then(argument("player", EntityArgumentType.player()) * @return a fully populated command tree for the requested root
.then(argument("amount", LongArgumentType.longArg(1L)) */
.executes(context -> transferSouls(context, mod))))) private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) {
.then(literal("shop") return literal(rootName)
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) // Running the root command alone shows the player's own balance.
.executes(context -> openShop(context, mod, null)) .executes(context -> showOwnBalance(context, mod))
.then(argument("category", StringArgumentType.word()) // /soul balance
.executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category"))))) .then(literal("balance")
.then(literal("bounty") .executes(context -> showOwnBalance(context, mod))
.requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0)) .then(argument("player", EntityArgumentType.player())
.then(literal("place") // Only privileged sources can inspect another player's balance.
.then(argument("player", EntityArgumentType.player()) .requires(source -> mod.permissionService().hasAny(source, 2,
.then(argument("amount", LongArgumentType.longArg(1L)) mod.config().permissions().adminNode(),
.executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds())) mod.config().permissions().balanceOthersNode()))
.then(argument("durationSeconds", LongArgumentType.longArg(1L)) .executes(context -> showTargetBalance(context, mod))))
.executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds"))))))) // /soul pay <player> <amount>
.then(literal("list") .then(literal("pay")
.executes(context -> listBounties(context, mod, null)) .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.then(argument("player", EntityArgumentType.player()) .then(argument("player", EntityArgumentType.player())
.executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player")))))) .then(argument("amount", LongArgumentType.longArg(1L))
.then(literal("scoreboard") .executes(context -> transferSouls(context, mod)))))
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode())) // /soul shop [category]
.executes(context -> showScoreboardStatus(context, mod)) .then(literal("shop")
.then(literal("toggle") .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.executes(context -> toggleScoreboard(context, mod))) .executes(context -> openShop(context, mod, null))
.then(literal("on") .then(argument("category", StringArgumentType.word())
.executes(context -> setScoreboardVisibility(context, mod, true))) .executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category")))))
.then(literal("off") // /soul bounty ...
.executes(context -> setScoreboardVisibility(context, mod, false)))) .then(literal("bounty")
.then(literal("top") .requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0))
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode())) .then(literal("place")
.executes(context -> showLeaderboard(context, mod, 1)) .then(argument("player", EntityArgumentType.player())
.then(argument("page", IntegerArgumentType.integer(1)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page"))))) // Default duration comes from config unless the caller provides one explicitly.
.then(literal("reload") .executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds()))
.requires(source -> mod.permissionService().hasAny(source, 2, .then(argument("durationSeconds", LongArgumentType.longArg(1L))
mod.config().permissions().adminNode(), .executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds")))))))
mod.config().permissions().reloadNode())) .then(literal("list")
.executes(context -> reload(context, mod))) .executes(context -> listBounties(context, mod, null))
.then(literal("set") .then(argument("player", EntityArgumentType.player())
.requires(source -> mod.permissionService().hasAny(source, 2, .executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player"))))))
mod.config().permissions().adminNode(), // /soul scoreboard ...
mod.config().permissions().setNode())) .then(literal("scoreboard")
.then(argument("player", EntityArgumentType.player()) .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode()))
.then(argument("amount", LongArgumentType.longArg(0L)) .executes(context -> showScoreboardStatus(context, mod))
.executes(context -> setBalance(context, mod))))) // Toggle uses the player's stored preference.
.then(literal("add") .then(literal("toggle")
.requires(source -> mod.permissionService().hasAny(source, 2, .executes(context -> toggleScoreboard(context, mod)))
mod.config().permissions().adminNode(), // Explicit on/off commands are useful for scripts and exact control.
mod.config().permissions().addNode())) .then(literal("on")
.then(argument("player", EntityArgumentType.player()) .executes(context -> setScoreboardVisibility(context, mod, true)))
.then(literal("off")
.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")))))
// /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(),
mod.config().permissions().setNode()))
.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(),
mod.config().permissions().addNode()))
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> addBalance(context, mod))))) .executes(context -> addBalance(context, mod)))))
.then(literal("take") .then(literal("take")
.requires(source -> mod.permissionService().hasAny(source, 2, .requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(), mod.config().permissions().adminNode(),
mod.config().permissions().takeNode())) mod.config().permissions().takeNode()))
.then(argument("player", EntityArgumentType.player()) .then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> takeBalance(context, mod))))); .executes(context -> takeBalance(context, mod)))));
} }
@@ -143,13 +178,14 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
boolean visible = mod.hudService().isScoreboardVisible(player.getUuid()); boolean visible = mod.hudService().isScoreboardVisible(player.getUuid());
String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled."; String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled.";
if (!mod.config().hud().scoreboard().enabled()) { // This is a player preference; config can still disable the HUD globally.
message += " The server-wide HUD toggle is disabled in config."; if (!mod.config().hud().scoreboard().enabled()) {
} message += " The server-wide HUD toggle is disabled in config.";
}
String finalMessage = message; String finalMessage = message;
context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false); context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false);
return 1; return 1;
@@ -179,27 +215,45 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) { private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) {
HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page); HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page);
if (leaderboardPage.entries().isEmpty()) { if (leaderboardPage.entries().isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false); context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false);
return 1; return 1;
} }
int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize()); int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize());
context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false); context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false);
for (int index = 0; index < leaderboardPage.entries().size(); index++) { for (int index = 0; index < leaderboardPage.entries().size(); index++) {
HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index); HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index);
int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1; // Convert the page-local index into the stable 1-based rank shown to players.
context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false); 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; }
} 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"); * Gives a tracker compass to one player that points at another player.
long amount = LongArgumentType.getLong(context, "amount"); *
* @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");
long amount = LongArgumentType.getLong(context, "amount");
BountyService.PlaceBountyResult result = mod.bountyService().placeBounty( BountyService.PlaceBountyResult result = mod.bountyService().placeBounty(
placer.getUuid(), placer.getUuid(),
@@ -228,12 +282,13 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false); context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false);
for (StoredBounty bounty : bounties) { for (StoredBounty bounty : bounties) {
long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L); // Round up so a bounty that expires in a fraction of a second still reports 1 second remaining.
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); 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; }
return 1;
} }
private static int reload(CommandContext<ServerCommandSource> context, SoulStealMod mod) { private static int reload(CommandContext<ServerCommandSource> context, SoulStealMod mod) {
@@ -269,4 +324,4 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true); context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true);
return 1; return 1;
} }
} }
@@ -23,7 +23,7 @@ public final class SoulStealData {
private Map<String, String> playerNames = new HashMap<>(); private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>(); private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
public SoulStealData normalize() { public SoulStealData normalize() {
if (souls == null) { if (souls == null) {
souls = new HashMap<>(); souls = new HashMap<>();
} }
@@ -48,38 +48,78 @@ public final class SoulStealData {
if (scoreboardVisibility == null) { if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>(); scoreboardVisibility = new HashMap<>();
} }
return this; return this;
} }
public Map<String, Long> souls() { /**
* Returns the persistent soul balance table keyed by player UUID string.
*
* @return mutable soul balance map
*/
public Map<String, Long> souls() {
return souls; return souls;
} }
public List<StoredBounty> activeBounties() { /**
* Returns the active bounty list.
*
* @return mutable list of active bounties
*/
public List<StoredBounty> activeBounties() {
return activeBounties; return activeBounties;
} }
public Map<String, Set<String>> unlockedEntries() { /**
* Returns the set of shop entries unlocked per player.
*
* @return mutable unlock table
*/
public Map<String, Set<String>> unlockedEntries() {
return unlockedEntries; return unlockedEntries;
} }
public Map<String, Map<String, Long>> purchaseCooldowns() { /**
* Returns the per-entry purchase cooldown table.
*
* @return mutable cooldown map
*/
public Map<String, Map<String, Long>> purchaseCooldowns() {
return purchaseCooldowns; return purchaseCooldowns;
} }
public Map<String, Map<String, Boolean>> grantedPermissions() { /**
* Returns the internal permission fallback table.
*
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions; return grantedPermissions;
} }
public Map<String, Long> bountyPlacementCooldowns() { /**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns; return bountyPlacementCooldowns;
} }
public Map<String, String> playerNames() { /**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames; return playerNames;
} }
public Map<String, Boolean> scoreboardVisibility() { /**
* Returns the per-player scoreboard visibility table.
*
* @return mutable scoreboard visibility map
*/
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility; return scoreboardVisibility;
} }
} }
@@ -24,12 +24,17 @@ public final class SoulStealDataStore {
private final Path dataFile; private final Path dataFile;
private SoulStealData data = new SoulStealData(); private SoulStealData data = new SoulStealData();
public SoulStealDataStore(Path dataDirectory) { public SoulStealDataStore(Path dataDirectory) {
this.dataDirectory = dataDirectory; this.dataDirectory = dataDirectory;
this.dataFile = dataDirectory.resolve("soulsteal-data.json"); this.dataFile = dataDirectory.resolve("soulsteal-data.json");
} }
public synchronized void load() throws IOException { /**
* 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); Files.createDirectories(dataDirectory);
if (Files.notExists(dataFile)) { if (Files.notExists(dataFile)) {
data = new SoulStealData(); data = new SoulStealData();
@@ -41,13 +46,23 @@ public final class SoulStealDataStore {
SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class); SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class);
data = loaded == null ? new SoulStealData() : loaded.normalize(); data = loaded == null ? new SoulStealData() : loaded.normalize();
} }
} }
public synchronized SoulStealData data() { /**
* Returns the in-memory persistent data snapshot.
*
* @return mutable data model used by the running server
*/
public synchronized SoulStealData data() {
return data; return data;
} }
public synchronized void save() throws IOException { /**
* 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); Files.createDirectories(dataDirectory);
Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp"); Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp");
try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
@@ -60,4 +75,4 @@ public final class SoulStealDataStore {
Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING); Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING);
} }
} }
} }
@@ -17,16 +17,31 @@ public record StoredBounty(
long soulValue, long soulValue,
long createdAtEpochMillis, long createdAtEpochMillis,
long expiresAtEpochMillis long expiresAtEpochMillis
) { ) {
public UUID idAsUuid() { /**
* Parses the bounty id as a UUID.
*
* @return bounty UUID
*/
public UUID idAsUuid() {
return UUID.fromString(id); return UUID.fromString(id);
} }
public UUID placerUuidAsUuid() { /**
* Parses the placer id as a UUID.
*
* @return placer UUID
*/
public UUID placerUuidAsUuid() {
return UUID.fromString(placerUuid); return UUID.fromString(placerUuid);
} }
public UUID targetUuidAsUuid() { /**
* Parses the target id as a UUID.
*
* @return target UUID
*/
public UUID targetUuidAsUuid() {
return UUID.fromString(targetUuid); return UUID.fromString(targetUuid);
} }
} }
@@ -18,13 +18,25 @@ public final class BountyService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final SoulService soulService; private final SoulService soulService;
public BountyService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore, SoulService soulService) { public BountyService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
this.soulService = soulService; this.soulService = soulService;
} }
public PlaceBountyResult placeBounty( /**
* 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, UUID placerUuid,
String placerName, String placerName,
UUID targetUuid, UUID targetUuid,
@@ -84,9 +96,16 @@ public final class BountyService {
data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L)); data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L));
saveQuietly(); saveQuietly();
return new PlaceBountyResult(true, "Bounty placed successfully.", bounty); return new PlaceBountyResult(true, "Bounty placed successfully.", bounty);
} }
public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) { /**
* 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<>(); List<StoredBounty> claimed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
long reward = 0L; long reward = 0L;
@@ -108,9 +127,15 @@ public final class BountyService {
} }
return new ClaimBountyResult(reward, claimed); return new ClaimBountyResult(reward, claimed);
} }
public List<StoredBounty> clearForTarget(UUID targetUuid) { /**
* 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<>(); List<StoredBounty> removed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
String targetKey = key(targetUuid); String targetKey = key(targetUuid);
@@ -130,9 +155,15 @@ public final class BountyService {
} }
return removed; return removed;
} }
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) { /**
* 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(); SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
List<ExpiredBountyPayout> payouts = new ArrayList<>(); List<ExpiredBountyPayout> payouts = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -155,18 +186,35 @@ public final class BountyService {
saveQuietly(); saveQuietly();
} }
return payouts; return payouts;
} }
public List<StoredBounty> activeBounties() { /**
* 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()); return List.copyOf(dataStore.data().activeBounties());
} }
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) { /**
* 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); String targetKey = key(targetUuid);
return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList(); return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList();
} }
public long nextPlacementTime(UUID placerUuid) { /**
* 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); return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L);
} }
@@ -193,4 +241,4 @@ public final class BountyService {
public record ExpiredBountyPayout(StoredBounty bounty, long reward) { public record ExpiredBountyPayout(StoredBounty bounty, long reward) {
} }
} }
@@ -41,29 +41,45 @@ public final class HudService {
private final Map<UUID, SidebarState> sidebars = new HashMap<>(); private final Map<UUID, SidebarState> sidebars = new HashMap<>();
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>(); private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
public HudService( public HudService(
Supplier<SoulStealConfig> configSupplier, Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore, SoulStealDataStore dataStore,
SoulService soulService, SoulService soulService,
BountyService bountyService BountyService bountyService
) { ) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
this.soulService = soulService; this.soulService = soulService;
this.bountyService = bountyService; this.bountyService = bountyService;
} }
public void handlePlayerJoin(ServerPlayerEntity player) { /**
* Records player metadata and refreshes their HUD state when they join.
*
* @param player joining player
*/
public void handlePlayerJoin(ServerPlayerEntity player) {
rememberPlayer(player); rememberPlayer(player);
refreshPlayerDisplays(player, System.currentTimeMillis()); refreshPlayerDisplays(player, System.currentTimeMillis());
} }
public void handlePlayerDisconnect(ServerPlayerEntity player) { /**
* Clears any per-player HUD state when a player disconnects.
*
* @param player disconnecting player
*/
public void handlePlayerDisconnect(ServerPlayerEntity player) {
clearSidebar(player); clearSidebar(player);
clearBossBar(player); clearBossBar(player);
} }
public void tick(MinecraftServer server, long nowEpochMillis) { /**
* 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<>(); Set<UUID> onlinePlayers = new HashSet<>();
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
onlinePlayers.add(player.getUuid()); onlinePlayers.add(player.getUuid());
@@ -79,14 +95,27 @@ public final class HudService {
entry.getValue().clearPlayers(); entry.getValue().clearPlayers();
return true; return true;
}); });
} }
public boolean isScoreboardVisible(UUID playerUuid) { /**
* 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() return dataStore.data().scoreboardVisibility()
.getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible()); .getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible());
} }
public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) { /**
* 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); rememberPlayer(player);
Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible); Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible);
if (!Objects.equals(previous, visible)) { if (!Objects.equals(previous, visible)) {
@@ -94,13 +123,25 @@ public final class HudService {
} }
refreshSidebar(player, System.currentTimeMillis()); refreshSidebar(player, System.currentTimeMillis());
return visible; return visible;
} }
public boolean toggleScoreboardVisible(ServerPlayerEntity player) { /**
* 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())); return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid()));
} }
public LeaderboardPage leaderboard(int requestedPage) { /**
* 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()); Set<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
playerKeys.addAll(dataStore.data().souls().keySet()); playerKeys.addAll(dataStore.data().souls().keySet());
@@ -283,4 +324,4 @@ public final class HudService {
public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) { public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) {
} }
} }
@@ -22,11 +22,19 @@ import net.minecraft.server.network.ServerPlayerEntity;
public final class PermissionService { public final class PermissionService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
public PermissionService(SoulStealDataStore dataStore) { public PermissionService(SoulStealDataStore dataStore) {
this.dataStore = dataStore; this.dataStore = dataStore;
} }
public boolean has(ServerCommandSource source, String permission, int defaultLevel) { /**
* 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)) { if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) {
return true; return true;
} }
@@ -37,18 +45,34 @@ public final class PermissionService {
} }
return source.getPlayer() == null || defaultLevel <= 0; return source.getPlayer() == null || defaultLevel <= 0;
} }
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) { /**
* 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) { for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) { if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) {
return true; return true;
} }
} }
return false; return false;
} }
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) { /**
* 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)) { if (hasStoredPermission(player.getUuid(), permission)) {
return true; return true;
} }
@@ -59,18 +83,35 @@ public final class PermissionService {
} }
return defaultValue; return defaultValue;
} }
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) { /**
* 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) { for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) { if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) {
return true; return true;
} }
} }
return false; return false;
} }
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) { /**
* 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 grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false; boolean storedInternally = false;
@@ -194,4 +235,4 @@ public final class PermissionService {
public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) { public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) {
} }
} }
@@ -49,36 +49,57 @@ public final class ShopService {
private final RewardService rewardService; private final RewardService rewardService;
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
public ShopService( public ShopService(
Supplier<ConfigBundle> bundleSupplier, Supplier<ConfigBundle> bundleSupplier,
SoulService soulService, SoulService soulService,
RewardService rewardService, RewardService rewardService,
SoulStealDataStore dataStore SoulStealDataStore dataStore
) { ) {
this.bundleSupplier = bundleSupplier; this.bundleSupplier = bundleSupplier;
this.soulService = soulService; this.soulService = soulService;
this.rewardService = rewardService; this.rewardService = rewardService;
this.dataStore = dataStore; this.dataStore = dataStore;
} }
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) { /**
* 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()) { if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) {
openView(player, resolveHomeView(requestedPage)); openView(player, resolveHomeView(requestedPage));
return; return;
} }
openView(player, resolveCategoryView(requestedCategoryKey, requestedPage)); openView(player, resolveCategoryView(requestedCategoryKey, requestedPage));
} }
public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) { /**
* 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) { return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView); case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView); case CategoryView categoryView -> createCategoryInventory(player, categoryView);
case AmountView amountView -> createAmountInventory(player, amountView); case AmountView amountView -> createAmountInventory(player, amountView);
}; };
} }
public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) { /**
* 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) { switch (view) {
case HomeView homeView -> handleHomeClick(player, homeView, slotIndex); case HomeView homeView -> handleHomeClick(player, homeView, slotIndex);
case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex); case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex);
@@ -525,4 +546,4 @@ public final class ShopService {
public record PurchaseResult(boolean success, String message) { public record PurchaseResult(boolean success, String message) {
} }
} }
@@ -13,21 +13,41 @@ public final class SoulService {
private final Supplier<SoulStealConfig> configSupplier; private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
public SoulService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore) { public SoulService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
} }
public long balanceOf(UUID playerUuid) { /**
* 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(); SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls()); return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls());
} }
public boolean hasSouls(UUID playerUuid, long amount) { /**
* 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); return balanceOf(playerUuid) >= Math.max(0L, amount);
} }
public long addSouls(UUID playerUuid, long 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) { if (amount <= 0L) {
return balanceOf(playerUuid); return balanceOf(playerUuid);
} }
@@ -37,9 +57,16 @@ public final class SoulService {
long updated = Math.min(economy.maxSouls(), current + amount); long updated = Math.min(economy.maxSouls(), current + amount);
updateBalance(playerUuid, updated); updateBalance(playerUuid, updated);
return updated; return updated;
} }
public long removeSouls(UUID playerUuid, long amount) { /**
* 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) { if (amount <= 0L) {
return balanceOf(playerUuid); return balanceOf(playerUuid);
} }
@@ -48,14 +75,26 @@ public final class SoulService {
long updated = Math.max(0L, current - amount); long updated = Math.max(0L, current - amount);
updateBalance(playerUuid, updated); updateBalance(playerUuid, updated);
return updated; return updated;
} }
public void setSouls(UUID playerUuid, long amount) { /**
* 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(); SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount))); updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount)));
} }
public SoulChange applyDeathPenalty(UUID playerUuid) { /**
* 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); long current = balanceOf(playerUuid);
if (current <= 0L) { if (current <= 0L) {
return new SoulChange(0L, 0L); return new SoulChange(0L, 0L);
@@ -71,9 +110,17 @@ public final class SoulService {
long newBalance = current - boundedLoss; long newBalance = current - boundedLoss;
updateBalance(playerUuid, newBalance); updateBalance(playerUuid, newBalance);
return new SoulChange(-boundedLoss, newBalance); return new SoulChange(-boundedLoss, newBalance);
} }
public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) { /**
* 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(); SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer();
if (!transferConfig.enabled()) { if (!transferConfig.enabled()) {
return new TransferResult(false, "Soul transfers are disabled on this server.", balanceOf(senderUuid), balanceOf(receiverUuid)); return new TransferResult(false, "Soul transfers are disabled on this server.", balanceOf(senderUuid), balanceOf(receiverUuid));
@@ -113,4 +160,4 @@ public final class SoulService {
public record TransferResult(boolean success, String message, long senderBalance, long receiverBalance) { public record TransferResult(boolean success, String message, long senderBalance, long receiverBalance) {
} }
} }
@@ -2,10 +2,16 @@ package com.g2806.soulsteal.util;
/** Formats small configuration-driven durations for chat messages and shop tooltips. */ /** Formats small configuration-driven durations for chat messages and shop tooltips. */
public final class DurationFormatter { public final class DurationFormatter {
private DurationFormatter() { private DurationFormatter() {
} }
public static String formatSeconds(long totalSeconds) { /**
* 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) { if (totalSeconds <= 0L) {
return "0s"; return "0s";
} }
@@ -33,4 +39,4 @@ public final class DurationFormatter {
} }
builder.append(value).append(suffix); builder.append(value).append(suffix);
} }
} }
@@ -6,26 +6,56 @@ import net.minecraft.util.Formatting;
/** Centralized chat text helpers so command and gameplay messaging stay consistent. */ /** Centralized chat text helpers so command and gameplay messaging stay consistent. */
public final class SoulTexts { public final class SoulTexts {
private SoulTexts() { private SoulTexts() {
} }
public static Text info(String message) { /**
* 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); return prefixed(message, Formatting.GRAY);
} }
public static Text success(String message) { /**
* 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); return prefixed(message, Formatting.GREEN);
} }
public static Text warning(String message) { /**
* 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); return prefixed(message, Formatting.GOLD);
} }
public static Text error(String message) { /**
* 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); return prefixed(message, Formatting.RED);
} }
public static MutableText accent(String message) { /**
* 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); return Text.literal(message).formatted(Formatting.AQUA);
} }
@@ -33,4 +63,4 @@ public final class SoulTexts {
return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA) return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA)
.append(Text.literal(message).formatted(formatting)); .append(Text.literal(message).formatted(formatting));
} }
} }