From 84e05eff7f35db3744322a9faf2c5739c9e70470 Mon Sep 17 00:00:00 2001 From: darwincereska Date: Fri, 8 May 2026 15:42:12 -0400 Subject: [PATCH] feat(commands): add admin tracker compass grant command and document it - add /souls tracker give 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 --- README.md | 19 +- docs/PROJECT.md | 276 +++++++++++++++++ .../com/g2806/soulsteal/SoulStealMod.java | 141 ++++++--- .../command/SoulCommandRegistrar.java | 277 +++++++++++------- .../g2806/soulsteal/data/SoulStealData.java | 94 ++++-- .../soulsteal/data/SoulStealDataStore.java | 41 ++- .../g2806/soulsteal/data/StoredBounty.java | 33 ++- .../soulsteal/service/BountyService.java | 100 +++++-- .../g2806/soulsteal/service/HudService.java | 105 +++++-- .../soulsteal/service/PermissionService.java | 77 +++-- .../g2806/soulsteal/service/ShopService.java | 55 ++-- .../g2806/soulsteal/service/SoulService.java | 97 ++++-- .../soulsteal/util/DurationFormatter.java | 16 +- .../com/g2806/soulsteal/util/SoulTexts.java | 64 ++-- 14 files changed, 1044 insertions(+), 351 deletions(-) create mode 100644 docs/PROJECT.md diff --git a/README.md b/README.md index 83a700f..6f6b78a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# 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 + +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 @@ -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 bounty place [durationSeconds]` | All players | Places a timed bounty on another player. | | `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. | -| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. | -| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. | -| `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. | -| `/souls set|add|take ` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | +| `/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 ` | 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 ` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | ## 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/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. | -| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. | \ No newline at end of file +| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. | diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..cb9a663 --- /dev/null +++ b/docs/PROJECT.md @@ -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 `. 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 ` + - Shows another player’s balance. +- `/souls pay ` + - Transfers souls to another player. +- `/souls shop [category]` + - Opens the shop GUI. +- `/souls bounty place [durationSeconds]` + - Places a bounty. +- `/souls bounty list [player]` + - Lists active bounties. +- `/souls scoreboard` + - Shows scoreboard visibility state. +- `/souls scoreboard toggle` + - Toggles the player’s 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 ` + - Gives a tracker compass to one player that points at another player. + +### Admin Commands + +- `/souls reload` + - Reloads config and shop definitions. +- `/souls set ` + - Sets a balance directly. +- `/souls add ` + - Adds souls to a balance. +- `/souls take ` + - 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. diff --git a/src/main/java/com/g2806/soulsteal/SoulStealMod.java b/src/main/java/com/g2806/soulsteal/SoulStealMod.java index 9e062d9..10e2e79 100644 --- a/src/main/java/com/g2806/soulsteal/SoulStealMod.java +++ b/src/main/java/com/g2806/soulsteal/SoulStealMod.java @@ -51,8 +51,12 @@ public final class SoulStealMod implements ModInitializer { private ShopService shopService; 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"); configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID); @@ -138,9 +142,15 @@ public final class SoulStealMod implements ModInitializer { } 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 { configBundle = ConfigBundle.load(configDirectory); return true; @@ -156,41 +166,86 @@ public final class SoulStealMod implements ModInitializer { } catch (IOException exception) { LOGGER.error("Failed to save Soul Steal data.", exception); } - } - - public ConfigBundle bundle() { - return configBundle; - } - - public SoulStealConfig config() { - return configBundle.config(); - } - - public SoulService soulService() { - return soulService; - } - - public PermissionService permissionService() { - return permissionService; - } - - public BountyService bountyService() { - return bountyService; - } - - public RewardService rewardService() { - return rewardService; - } - - public TrackerCompassService trackerCompassService() { - return trackerCompassService; - } - - public ShopService shopService() { - return shopService; - } - - public HudService hudService() { - return hudService; - } -} \ No newline at end of file + } + + /** + * 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; + } +} diff --git a/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java index e40faa9..95c9ded 100644 --- a/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java +++ b/src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java @@ -22,84 +22,119 @@ import static net.minecraft.server.command.CommandManager.literal; /** Registers the public command surface for Soul Steal. */ public final class SoulCommandRegistrar { - private SoulCommandRegistrar() { - } - - public static void register(CommandDispatcher dispatcher, SoulStealMod mod) { - dispatcher.register(buildRoot("souls", mod)); - dispatcher.register(buildRoot("soul", mod)); - } - - private static com.mojang.brigadier.builder.LiteralArgumentBuilder buildRoot(String rootName, SoulStealMod mod) { - return literal(rootName) - .executes(context -> showOwnBalance(context, mod)) - .then(literal("balance") - .executes(context -> showOwnBalance(context, mod)) - .then(argument("player", EntityArgumentType.player()) - .requires(source -> mod.permissionService().hasAny(source, 2, - mod.config().permissions().adminNode(), - mod.config().permissions().balanceOthersNode())) - .executes(context -> showTargetBalance(context, mod)))) - .then(literal("pay") - .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) - .then(argument("player", EntityArgumentType.player()) - .then(argument("amount", LongArgumentType.longArg(1L)) - .executes(context -> transferSouls(context, mod))))) - .then(literal("shop") - .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) - .executes(context -> openShop(context, mod, null)) - .then(argument("category", StringArgumentType.word()) - .executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category"))))) - .then(literal("bounty") - .requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0)) - .then(literal("place") - .then(argument("player", EntityArgumentType.player()) - .then(argument("amount", LongArgumentType.longArg(1L)) - .executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds())) - .then(argument("durationSeconds", LongArgumentType.longArg(1L)) - .executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds"))))))) - .then(literal("list") - .executes(context -> listBounties(context, mod, null)) - .then(argument("player", EntityArgumentType.player()) - .executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player")))))) - .then(literal("scoreboard") - .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode())) - .executes(context -> showScoreboardStatus(context, mod)) - .then(literal("toggle") - .executes(context -> toggleScoreboard(context, mod))) - .then(literal("on") - .executes(context -> setScoreboardVisibility(context, mod, true))) - .then(literal("off") - .executes(context -> setScoreboardVisibility(context, mod, false)))) - .then(literal("top") - .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode())) - .executes(context -> showLeaderboard(context, mod, 1)) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page"))))) - .then(literal("reload") - .requires(source -> mod.permissionService().hasAny(source, 2, - mod.config().permissions().adminNode(), - mod.config().permissions().reloadNode())) - .executes(context -> reload(context, mod))) - .then(literal("set") - .requires(source -> mod.permissionService().hasAny(source, 2, - mod.config().permissions().adminNode(), - mod.config().permissions().setNode())) - .then(argument("player", EntityArgumentType.player()) - .then(argument("amount", LongArgumentType.longArg(0L)) - .executes(context -> setBalance(context, mod))))) - .then(literal("add") - .requires(source -> mod.permissionService().hasAny(source, 2, - mod.config().permissions().adminNode(), - mod.config().permissions().addNode())) - .then(argument("player", EntityArgumentType.player()) + 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 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 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()) + // 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 + .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"))))))) + .then(literal("list") + .executes(context -> listBounties(context, mod, null)) + .then(argument("player", EntityArgumentType.player()) + .executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player")))))) + // /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))) + // Explicit on/off commands are useful for scripts and exact control. + .then(literal("on") + .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 + .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)) .executes(context -> addBalance(context, mod))))) - .then(literal("take") - .requires(source -> mod.permissionService().hasAny(source, 2, - mod.config().permissions().adminNode(), - mod.config().permissions().takeNode())) - .then(argument("player", EntityArgumentType.player()) + .then(literal("take") + .requires(source -> mod.permissionService().hasAny(source, 2, + mod.config().permissions().adminNode(), + mod.config().permissions().takeNode())) + .then(argument("player", EntityArgumentType.player()) .then(argument("amount", LongArgumentType.longArg(1L)) .executes(context -> takeBalance(context, mod))))); } @@ -143,13 +178,14 @@ public final class SoulCommandRegistrar { return 1; } - private static int showScoreboardStatus(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { - ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); - boolean visible = mod.hudService().isScoreboardVisible(player.getUuid()); - String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled."; - if (!mod.config().hud().scoreboard().enabled()) { - message += " The server-wide HUD toggle is disabled in config."; - } + private static int showScoreboardStatus(CommandContext context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); + boolean visible = mod.hudService().isScoreboardVisible(player.getUuid()); + String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled."; + // 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."; + } String finalMessage = message; context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false); return 1; @@ -179,27 +215,45 @@ public final class SoulCommandRegistrar { return 1; } - private static int showLeaderboard(CommandContext context, SoulStealMod mod, int page) { - HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page); - if (leaderboardPage.entries().isEmpty()) { - context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false); - return 1; - } - - int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize()); - context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false); - for (int index = 0; index < leaderboardPage.entries().size(); index++) { - HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index); - int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1; - context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false); - } - return 1; - } - - private static int placeBounty(CommandContext context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException { - ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); - ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); - long amount = LongArgumentType.getLong(context, "amount"); + private static int showLeaderboard(CommandContext context, SoulStealMod mod, int page) { + HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page); + if (leaderboardPage.entries().isEmpty()) { + context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false); + return 1; + } + + int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize()); + context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false); + for (int index = 0; index < leaderboardPage.entries().size(); index++) { + HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index); + // 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 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 context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); + ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); + long amount = LongArgumentType.getLong(context, "amount"); BountyService.PlaceBountyResult result = mod.bountyService().placeBounty( placer.getUuid(), @@ -228,12 +282,13 @@ public final class SoulCommandRegistrar { return 1; } - context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false); - for (StoredBounty bounty : bounties) { - long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L); - context.getSource().sendFeedback(() -> Text.literal("- " + bounty.targetName() + " | " + bounty.soulValue() + " souls | by " + bounty.placerName() + " | expires in " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(net.minecraft.util.Formatting.GRAY), false); - } - return 1; + 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); + } + return 1; } private static int reload(CommandContext 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); return 1; } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java index c6a477c..9278fb7 100644 --- a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java @@ -23,7 +23,7 @@ public final class SoulStealData { private Map playerNames = new HashMap<>(); private Map scoreboardVisibility = new HashMap<>(); - public SoulStealData normalize() { + public SoulStealData normalize() { if (souls == null) { souls = new HashMap<>(); } @@ -48,38 +48,78 @@ public final class SoulStealData { if (scoreboardVisibility == null) { scoreboardVisibility = new HashMap<>(); } - return this; - } - - public Map souls() { + return this; + } + + /** + * Returns the persistent soul balance table keyed by player UUID string. + * + * @return mutable soul balance map + */ + public Map souls() { return souls; - } - - public List activeBounties() { + } + + /** + * Returns the active bounty list. + * + * @return mutable list of active bounties + */ + public List activeBounties() { return activeBounties; - } - - public Map> unlockedEntries() { + } + + /** + * Returns the set of shop entries unlocked per player. + * + * @return mutable unlock table + */ + public Map> unlockedEntries() { return unlockedEntries; - } - - public Map> purchaseCooldowns() { + } + + /** + * Returns the per-entry purchase cooldown table. + * + * @return mutable cooldown map + */ + public Map> purchaseCooldowns() { return purchaseCooldowns; - } - - public Map> grantedPermissions() { + } + + /** + * Returns the internal permission fallback table. + * + * @return mutable permission map + */ + public Map> grantedPermissions() { return grantedPermissions; - } - - public Map bountyPlacementCooldowns() { + } + + /** + * Returns the bounty placement cooldown table. + * + * @return mutable placement cooldown map + */ + public Map bountyPlacementCooldowns() { return bountyPlacementCooldowns; - } - - public Map playerNames() { + } + + /** + * Returns the last known player names table. + * + * @return mutable player-name map + */ + public Map playerNames() { return playerNames; - } - - public Map scoreboardVisibility() { + } + + /** + * Returns the per-player scoreboard visibility table. + * + * @return mutable scoreboard visibility map + */ + public Map scoreboardVisibility() { return scoreboardVisibility; } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java b/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java index 23251d9..896d592 100644 --- a/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java @@ -24,12 +24,17 @@ public final class SoulStealDataStore { private final Path dataFile; private SoulStealData data = new SoulStealData(); - public SoulStealDataStore(Path dataDirectory) { - this.dataDirectory = dataDirectory; - this.dataFile = dataDirectory.resolve("soulsteal-data.json"); - } - - public synchronized void load() throws IOException { + public SoulStealDataStore(Path dataDirectory) { + this.dataDirectory = dataDirectory; + 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)) { data = new SoulStealData(); @@ -41,13 +46,23 @@ public final class SoulStealDataStore { SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class); 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; - } - - 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); Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp"); try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { @@ -60,4 +75,4 @@ public final class SoulStealDataStore { Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/data/StoredBounty.java b/src/main/java/com/g2806/soulsteal/data/StoredBounty.java index 4ba3be8..765eacb 100644 --- a/src/main/java/com/g2806/soulsteal/data/StoredBounty.java +++ b/src/main/java/com/g2806/soulsteal/data/StoredBounty.java @@ -17,16 +17,31 @@ public record StoredBounty( long soulValue, long createdAtEpochMillis, long expiresAtEpochMillis -) { - public UUID idAsUuid() { +) { + /** + * Parses the bounty id as a UUID. + * + * @return bounty UUID + */ + public UUID idAsUuid() { return UUID.fromString(id); - } - - public UUID placerUuidAsUuid() { + } + + /** + * Parses the placer id as a UUID. + * + * @return placer UUID + */ + public UUID placerUuidAsUuid() { return UUID.fromString(placerUuid); - } - - public UUID targetUuidAsUuid() { + } + + /** + * Parses the target id as a UUID. + * + * @return target UUID + */ + public UUID targetUuidAsUuid() { return UUID.fromString(targetUuid); } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/service/BountyService.java b/src/main/java/com/g2806/soulsteal/service/BountyService.java index ab1929b..a7be5fb 100644 --- a/src/main/java/com/g2806/soulsteal/service/BountyService.java +++ b/src/main/java/com/g2806/soulsteal/service/BountyService.java @@ -18,13 +18,25 @@ public final class BountyService { private final SoulStealDataStore dataStore; private final SoulService soulService; - public BountyService(Supplier configSupplier, SoulStealDataStore dataStore, SoulService soulService) { - this.configSupplier = configSupplier; - this.dataStore = dataStore; - this.soulService = soulService; - } - - public PlaceBountyResult placeBounty( + public BountyService(Supplier configSupplier, SoulStealDataStore dataStore, SoulService soulService) { + this.configSupplier = configSupplier; + this.dataStore = dataStore; + 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, UUID targetUuid, @@ -84,9 +96,16 @@ public final class BountyService { data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L)); saveQuietly(); 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 claimed = new ArrayList<>(); Iterator iterator = dataStore.data().activeBounties().iterator(); long reward = 0L; @@ -108,9 +127,15 @@ public final class BountyService { } return new ClaimBountyResult(reward, claimed); - } - - public List 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 clearForTarget(UUID targetUuid) { List removed = new ArrayList<>(); Iterator iterator = dataStore.data().activeBounties().iterator(); String targetKey = key(targetUuid); @@ -130,9 +155,15 @@ public final class BountyService { } return removed; - } - - public List 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 processExpirations(long nowEpochMillis) { SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty(); List payouts = new ArrayList<>(); Iterator iterator = dataStore.data().activeBounties().iterator(); @@ -155,18 +186,35 @@ public final class BountyService { saveQuietly(); } return payouts; - } - - public List activeBounties() { + } + + /** + * Returns a snapshot of all active bounties. + * + * @return immutable copy of the current active bounty list + */ + public List activeBounties() { return List.copyOf(dataStore.data().activeBounties()); - } - - public List 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 activeBountiesForTarget(UUID targetUuid) { String targetKey = key(targetUuid); 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); } @@ -193,4 +241,4 @@ public final class BountyService { public record ExpiredBountyPayout(StoredBounty bounty, long reward) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/service/HudService.java b/src/main/java/com/g2806/soulsteal/service/HudService.java index 4e73ec4..c722730 100644 --- a/src/main/java/com/g2806/soulsteal/service/HudService.java +++ b/src/main/java/com/g2806/soulsteal/service/HudService.java @@ -41,29 +41,45 @@ public final class HudService { private final Map sidebars = new HashMap<>(); private final Map bountyBossBars = new HashMap<>(); - public HudService( - Supplier configSupplier, - SoulStealDataStore dataStore, - SoulService soulService, - BountyService bountyService - ) { - this.configSupplier = configSupplier; - this.dataStore = dataStore; - this.soulService = soulService; - this.bountyService = bountyService; - } - - public void handlePlayerJoin(ServerPlayerEntity player) { + public HudService( + Supplier configSupplier, + SoulStealDataStore dataStore, + SoulService soulService, + BountyService bountyService + ) { + this.configSupplier = configSupplier; + this.dataStore = dataStore; + this.soulService = soulService; + 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()); - } - - 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); 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 onlinePlayers = new HashSet<>(); for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { onlinePlayers.add(player.getUuid()); @@ -79,14 +95,27 @@ public final class HudService { entry.getValue().clearPlayers(); 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() .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); Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible); if (!Objects.equals(previous, visible)) { @@ -94,13 +123,25 @@ public final class HudService { } refreshSidebar(player, System.currentTimeMillis()); 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())); - } - - 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 playerKeys = new HashSet<>(dataStore.data().playerNames().keySet()); playerKeys.addAll(dataStore.data().souls().keySet()); @@ -283,4 +324,4 @@ public final class HudService { public record LeaderboardPage(int page, int totalPages, List entries) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/service/PermissionService.java b/src/main/java/com/g2806/soulsteal/service/PermissionService.java index cac4312..5f3679f 100644 --- a/src/main/java/com/g2806/soulsteal/service/PermissionService.java +++ b/src/main/java/com/g2806/soulsteal/service/PermissionService.java @@ -22,11 +22,19 @@ import net.minecraft.server.network.ServerPlayerEntity; public final class PermissionService { private final SoulStealDataStore dataStore; - public PermissionService(SoulStealDataStore dataStore) { - this.dataStore = dataStore; - } - - public boolean has(ServerCommandSource source, String permission, int defaultLevel) { + public PermissionService(SoulStealDataStore dataStore) { + 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; } @@ -37,18 +45,34 @@ public final class PermissionService { } 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) { if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) { return true; } } 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)) { return true; } @@ -59,18 +83,35 @@ public final class PermissionService { } 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) { if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) { return true; } } 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 storedInternally = false; @@ -194,4 +235,4 @@ public final class PermissionService { public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/service/ShopService.java b/src/main/java/com/g2806/soulsteal/service/ShopService.java index 7f5f54f..f56206b 100644 --- a/src/main/java/com/g2806/soulsteal/service/ShopService.java +++ b/src/main/java/com/g2806/soulsteal/service/ShopService.java @@ -49,36 +49,57 @@ public final class ShopService { private final RewardService rewardService; private final SoulStealDataStore dataStore; - public ShopService( - Supplier bundleSupplier, - SoulService soulService, - RewardService rewardService, - SoulStealDataStore dataStore + public ShopService( + Supplier bundleSupplier, + SoulService soulService, + RewardService rewardService, + SoulStealDataStore dataStore ) { this.bundleSupplier = bundleSupplier; this.soulService = soulService; - this.rewardService = rewardService; - this.dataStore = dataStore; - } - - public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) { + this.rewardService = rewardService; + 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)); return; } 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) { case HomeView homeView -> createHomeInventory(player, homeView); case CategoryView categoryView -> createCategoryInventory(player, categoryView); case AmountView amountView -> createAmountInventory(player, amountView); }; - } - - public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) { + } + + /** + * 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); case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex); @@ -525,4 +546,4 @@ public final class ShopService { public record PurchaseResult(boolean success, String message) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/service/SoulService.java b/src/main/java/com/g2806/soulsteal/service/SoulService.java index 9471d1b..e49f3de 100644 --- a/src/main/java/com/g2806/soulsteal/service/SoulService.java +++ b/src/main/java/com/g2806/soulsteal/service/SoulService.java @@ -13,21 +13,41 @@ public final class SoulService { private final Supplier configSupplier; private final SoulStealDataStore dataStore; - public SoulService(Supplier configSupplier, SoulStealDataStore dataStore) { - this.configSupplier = configSupplier; - this.dataStore = dataStore; - } - - public long balanceOf(UUID playerUuid) { + public SoulService(Supplier configSupplier, SoulStealDataStore dataStore) { + this.configSupplier = configSupplier; + 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()); - } - - 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); - } - - 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) { return balanceOf(playerUuid); } @@ -37,9 +57,16 @@ public final class SoulService { long updated = Math.min(economy.maxSouls(), current + amount); updateBalance(playerUuid, 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) { return balanceOf(playerUuid); } @@ -48,14 +75,26 @@ public final class SoulService { long updated = Math.max(0L, current - amount); updateBalance(playerUuid, 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(); 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); if (current <= 0L) { return new SoulChange(0L, 0L); @@ -71,9 +110,17 @@ public final class SoulService { long newBalance = current - boundedLoss; updateBalance(playerUuid, 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(); if (!transferConfig.enabled()) { 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) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java b/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java index ae84712..cc30a7f 100644 --- a/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java +++ b/src/main/java/com/g2806/soulsteal/util/DurationFormatter.java @@ -2,10 +2,16 @@ package com.g2806.soulsteal.util; /** Formats small configuration-driven durations for chat messages and shop tooltips. */ public final class DurationFormatter { - private DurationFormatter() { - } - - public static String formatSeconds(long totalSeconds) { + 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"; } @@ -33,4 +39,4 @@ public final class DurationFormatter { } builder.append(value).append(suffix); } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/util/SoulTexts.java b/src/main/java/com/g2806/soulsteal/util/SoulTexts.java index 55f647e..34c4166 100644 --- a/src/main/java/com/g2806/soulsteal/util/SoulTexts.java +++ b/src/main/java/com/g2806/soulsteal/util/SoulTexts.java @@ -6,26 +6,56 @@ import net.minecraft.util.Formatting; /** Centralized chat text helpers so command and gameplay messaging stay consistent. */ public final class SoulTexts { - private SoulTexts() { - } - - public static Text info(String message) { + 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); - } - - 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); - } - - 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); - } - - 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); - } - - 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); } @@ -33,4 +63,4 @@ public final class SoulTexts { return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA) .append(Text.literal(message).formatted(formatting)); } -} \ No newline at end of file +}