3 Commits

Author SHA1 Message Date
darwincereska 5f0085d2ce fix(contracts): wire home-page clear action and hide completed one-time contracts 2026-05-09 11:44:39 -04:00
darwincereska 024630d96c feat(contracts): add server-side contract browser and progress tracking 2026-05-09 11:16:46 -04:00
darwincereska 84e05eff7f 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
2026-05-08 15:42:12 -04:00
23 changed files with 2102 additions and 591 deletions
+8 -1
View File
@@ -2,6 +2,8 @@
Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bounties, tracker compasses, and a vanilla-compatible soul shop.
Full project documentation is available in [docs/PROJECT.md](docs/PROJECT.md).
## Requirements
| Requirement | Value |
@@ -13,7 +15,7 @@ Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bo
## How It Works
Players gain souls for killing other players and lose souls whenever they die, with all values driven by `config.yml`. The bounty system lets players spend souls to place timed bounties that pay killers on claim or reward survivors on expiry, while wanted players can see a bounty timer bossbar. The optional HUD sidebar can be toggled per player, and `/souls top` shows the configured leaderboard. The shop is a server-side chest GUI with a category home page, arrow pagination, optional reward display names, and item listings that can open a quantity selector.
Players gain souls for killing other players and lose souls whenever they die, with all values driven by `config.yml`. The bounty system lets players spend souls to place timed bounties that pay killers on claim or reward survivors on expiry, while wanted players can see a bounty timer bossbar. The optional HUD sidebar can be toggled per player, uses the same dark aqua title styling as the mod prefix, color-codes the visible lines, and only shows contract or bounty rows when they are actually active. `/souls top` shows the configured leaderboard. The shop is a server-side chest GUI with a category home page, arrow pagination, optional reward display names, and item listings that can open a quantity selector.
## Commands
@@ -27,6 +29,10 @@ Players gain souls for killing other players and lose souls whenever they die, w
| `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. |
| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. |
| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. |
| `/souls tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. |
| `/souls contracts` | All players | Opens the contract browser GUI. |
| `/souls contracts selected` | All players | Shows the currently selected contract and progress. |
| `/souls contracts clear` | All players | Clears your selected contract. |
| `/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. |
@@ -36,4 +42,5 @@ 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/catalog.yml` | Grouped mining and hunting contract sections, internal ids, player-facing names, icons, targets, progress amounts, rewards, and repeatable/one-time behavior. |
| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. |
+341
View File
@@ -0,0 +1,341 @@
# 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.
### Contracts
Contracts let players earn souls without PvP by completing mining and hunting goals.
Behavior:
- Players pick one active contract at a time.
- Mining contracts track block breaks against a configured block id.
- Hunting contracts track kills against a configured entity id.
- The selected contract and its progress appear in the HUD sidebar.
- Completing a contract pays souls automatically.
- One-time contracts disappear from the browser after completion and cannot be selected again.
- The contract browser uses `catalog.yml`, where the YAML key is the internal contract id and `name` is the player-facing label.
- Contract entries are organized under `contracts: mining:` and `contracts: hunting:` sections for readability, while still using per-entry ids.
- The contract browser uses a bottom control row like the shop, including page navigation anchored to the bottom of the inventory.
- The contract browser includes a clear-selection action on the home page and inside contract categories so players can remove their active contract from the GUI.
Implemented by:
- [`ContractService`](../src/main/java/com/g2806/soulsteal/service/ContractService.java)
- [`ContractGuiService`](../src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java)
- [`ContractCatalog`](../src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java)
- [`ContractDefinition`](../src/main/java/com/g2806/soulsteal/contract/ContractDefinition.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.
- The sidebar title uses the same dark aqua styling as the mod's chat prefix.
- Contract and bounty rows only appear while the player has an active selected contract or active bounties.
- Balance, contract, and bounty rows use color to make the sidebar easier to scan.
- 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.
- `/souls contracts`
- Opens the contract browser GUI.
- `/souls contracts selected`
- Shows the currently selected contract and progress.
- `/souls contracts clear`
- Clears your selected contract.
### 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/catalog.yml`
Contract catalog definition. It controls:
- Grouped contract lists under `mining` and `hunting`
- Internal contract ids from each entry `id`
- Player-facing contract names
- Icon item ids
- Target block or mob ids
- Required amounts and rewards
- Repeatable 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
- Selected contracts
- Contract progress
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.
### HUD Sidebar
When the sidebar is visible:
1. The title is rendered in dark aqua.
2. The soul balance row is always shown.
3. Contract rows are only shown if the player has an active selected contract.
4. Bounty rows are only shown if the player currently has active bounties.
5. The visible rows use color to distinguish balance, contract, and bounty information.
### 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.
4. Any matching hunting contract progresses if the killer has one selected.
### Block Break
When a player breaks a block:
1. Any matching mining contract progresses if the player has one selected.
### 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/java/com/g2806/soulsteal/contract/`
- Contract catalog, GUI, and screen handling classes.
- `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.
- Contract configuration is separated into `catalog.yml` so contract entries can be expanded without changing the main economy config.
+1 -1
View File
@@ -10,7 +10,7 @@ loom_version=1.16.1
fabric_api_version=0.141.3+1.21.11
# Mod Properties
mod_version=0.3.0
mod_version=0.4.0
maven_group=com.g2806.soulsteal
archives_base_name=soul-steal
@@ -5,6 +5,8 @@ import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.contract.ContractGuiService;
import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.RewardService;
@@ -21,9 +23,11 @@ import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.registry.Registries;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
@@ -46,11 +50,17 @@ public final class SoulStealMod implements ModInitializer {
private SoulService soulService;
private PermissionService permissionService;
private BountyService bountyService;
private ContractService contractService;
private ContractGuiService contractGuiService;
private RewardService rewardService;
private TrackerCompassService trackerCompassService;
private ShopService shopService;
private HudService hudService;
/**
* Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers.
*/
@Override
public void onInitialize() {
LOGGER.info("Initializing Soul Steal");
@@ -67,10 +77,12 @@ public final class SoulStealMod implements ModInitializer {
permissionService = new PermissionService(dataStore);
soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService);
contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
rewardService = new RewardService(permissionService, soulService);
contractGuiService = new ContractGuiService(this::bundle, contractService, rewardService, soulService);
trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService);
hudService = new HudService(this::config, dataStore, soulService, bountyService, contractService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
@@ -83,6 +95,16 @@ public final class SoulStealMod implements ModInitializer {
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) {
onPlayerDeath(player, damageSource);
return;
}
if (damageSource.getAttacker() instanceof ServerPlayerEntity killer) {
contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString());
}
});
PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
if (player instanceof ServerPlayerEntity serverPlayer) {
contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
}
});
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
@@ -101,7 +123,6 @@ public final class SoulStealMod implements ModInitializer {
MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
}
trackerCompassService.giveTrackerCompass(killer, victim);
}
@@ -140,6 +161,12 @@ public final class SoulStealMod implements ModInitializer {
hudService.tick(server, now);
}
/**
* Reloads the YAML configuration bundle from disk.
*
* @return {@code true} if the reload succeeded; {@code false} if the config file could not be
* loaded
*/
public boolean reloadConfiguration() {
try {
configBundle = ConfigBundle.load(configDirectory);
@@ -158,38 +185,91 @@ public final class SoulStealMod implements ModInitializer {
}
}
/**
* Returns the loaded configuration bundle.
*
* @return the current configuration bundle
*/
public ConfigBundle bundle() {
return configBundle;
}
/**
* Returns the active mod configuration.
*
* @return the current configuration tree
*/
public SoulStealConfig config() {
return configBundle.config();
}
/**
* Returns the service responsible for balance changes.
*
* @return the soul service
*/
public SoulService soulService() {
return soulService;
}
/**
* Returns the permission service.
*
* @return the permission service
*/
public PermissionService permissionService() {
return permissionService;
}
/**
* Returns the bounty service.
*
* @return the bounty service
*/
public BountyService bountyService() {
return bountyService;
}
public ContractService contractService() {
return contractService;
}
public ContractGuiService contractGuiService() {
return contractGuiService;
}
/**
* 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;
}
@@ -1,6 +1,7 @@
package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService;
@@ -25,36 +26,57 @@ public final class SoulCommandRegistrar {
private SoulCommandRegistrar() {
}
/**
* Registers the public command roots exposed by the mod.
*
* @param dispatcher Brigadier dispatcher used by Fabric to install commands
* @param mod active mod instance used to resolve services and configuration
*/
public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) {
// Register both command roots so players can use either the full name or the short alias.
dispatcher.register(buildRoot("souls", mod));
dispatcher.register(buildRoot("soul", mod));
}
/**
* Builds one of the root command aliases and all nested subcommands.
*
* @param rootName literal command root to register, such as {@code souls} or {@code soul}
* @param mod active mod instance used to resolve services and permissions
* @return a fully populated command tree for the requested root
*/
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) {
return literal(rootName)
// Running the root command alone shows the player's own balance.
.executes(context -> showOwnBalance(context, mod))
// /soul balance
.then(literal("balance")
.executes(context -> showOwnBalance(context, mod))
.then(argument("player", EntityArgumentType.player())
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().balanceOthersNode()))
// Only privileged sources can inspect another player's balance.
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().balanceOthersNode()))
.executes(context -> showTargetBalance(context, mod))))
// /soul pay <player> <amount>
.then(literal("pay")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> transferSouls(context, mod)))))
// /soul shop [category]
.then(literal("shop")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.executes(context -> openShop(context, mod, null))
.then(argument("category", StringArgumentType.word())
.executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category")))))
// /soul bounty ...
.then(literal("bounty")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0))
.then(literal("place")
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L))
// Default duration comes from config unless the caller provides one explicitly.
.executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds()))
.then(argument("durationSeconds", LongArgumentType.longArg(1L))
.executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds")))))))
@@ -62,25 +84,45 @@ public final class SoulCommandRegistrar {
.executes(context -> listBounties(context, mod, null))
.then(argument("player", EntityArgumentType.player())
.executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player"))))))
.then(literal("scoreboard")
// /soul scoreboard ...
.then(literal("scoreboard")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode()))
.executes(context -> showScoreboardStatus(context, mod))
// Toggle uses the player's stored preference.
.then(literal("toggle")
.executes(context -> toggleScoreboard(context, mod)))
.executes(context -> toggleScoreboard(context, mod)))
// Explicit on/off commands are useful for scripts and exact control.
.then(literal("on")
.executes(context -> setScoreboardVisibility(context, mod, true)))
.executes(context -> setScoreboardVisibility(context, mod, true)))
.then(literal("off")
.executes(context -> setScoreboardVisibility(context, mod, false))))
.then(literal("top")
.executes(context -> setScoreboardVisibility(context, mod, false))))
// /soul top [page]
.then(literal("top")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode()))
.executes(context -> showLeaderboard(context, mod, 1))
.then(argument("page", IntegerArgumentType.integer(1))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page")))))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page")))))
// /soul tracker give <player> <target>
.then(literal("tracker")
.requires(source -> mod.permissionService().hasAny(source, 2, mod.config().permissions().adminNode()))
.then(literal("give")
.then(argument("player", EntityArgumentType.player())
.then(argument("target", EntityArgumentType.player())
.executes(context -> giveTrackerCompass(context, mod))))))
// /soul contracts ...
.then(literal("contracts")
.executes(context -> openContracts(context, mod))
.then(literal("selected")
.executes(context -> showSelectedContract(context, mod)))
.then(literal("clear")
.executes(context -> clearSelectedContract(context, mod))))
// Admin-only maintenance and balance editing commands follow.
.then(literal("reload")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().reloadNode()))
.executes(context -> reload(context, mod)))
// Set replaces the target balance outright.
.then(literal("set")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
@@ -88,6 +130,7 @@ public final class SoulCommandRegistrar {
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(0L))
.executes(context -> setBalance(context, mod)))))
// Add and take are bounded changes; they keep the balance moving up or down.
.then(literal("add")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
@@ -147,6 +190,7 @@ public final class SoulCommandRegistrar {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
boolean visible = mod.hudService().isScoreboardVisible(player.getUuid());
String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled.";
// This is a player preference; config can still disable the HUD globally.
if (!mod.config().hud().scoreboard().enabled()) {
message += " The server-wide HUD toggle is disabled in config.";
}
@@ -190,12 +234,56 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false);
for (int index = 0; index < leaderboardPage.entries().size(); index++) {
HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index);
// Convert the page-local index into the stable 1-based rank shown to players.
int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1;
context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false);
}
return 1;
}
/**
* Gives a tracker compass to one player that points at another player.
*
* @param context command invocation context
* @param mod active mod instance
* @return command result code
* @throws com.mojang.brigadier.exceptions.CommandSyntaxException if either player argument cannot be resolved
*/
private static int giveTrackerCompass(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = EntityArgumentType.getPlayer(context, "player");
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "target");
mod.trackerCompassService().giveTrackerCompass(player, target);
context.getSource().sendFeedback(() -> SoulTexts.success("Gave a tracker compass to " + player.getName().getString() + " for " + target.getName().getString() + "."), true);
return 1;
}
private static int openContracts(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
mod.contractGuiService().openContracts(player);
return 1;
}
private static int showSelectedContract(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
java.util.Optional<ContractDefinition> selected = mod.contractService().selectedContract(player.getUuid());
if (selected.isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("You do not have a selected contract."), false);
return 1;
}
ContractDefinition contract = selected.get();
long progress = mod.contractService().progress(player.getUuid(), contract.id());
context.getSource().sendFeedback(() -> SoulTexts.info("Selected contract: " + contract.name() + " " + progress + "/" + contract.amountRequired() + " souls reward " + contract.reward()), false);
return 1;
}
private static int clearSelectedContract(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
mod.contractService().clearContract(player.getUuid());
context.getSource().sendFeedback(() -> SoulTexts.success("Cleared your selected contract."), false);
return 1;
}
private static int placeBounty(CommandContext<ServerCommandSource> context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity placer = context.getSource().getPlayerOrThrow();
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player");
@@ -230,6 +318,7 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false);
for (StoredBounty bounty : bounties) {
// Round up so a bounty that expires in a fraction of a second still reports 1 second remaining.
long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L);
context.getSource().sendFeedback(() -> Text.literal("- " + bounty.targetName() + " | " + bounty.soulValue() + " souls | by " + bounty.placerName() + " | expires in " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(net.minecraft.util.Formatting.GRAY), false);
}
@@ -1,13 +1,14 @@
package com.g2806.soulsteal.config;
import com.g2806.soulsteal.shop.ShopCatalog;
import com.g2806.soulsteal.contract.ContractCatalog;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** Loads and groups the editable YAML files used by the mod. */
public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog) {
public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog, ContractCatalog contractCatalog) {
public static ConfigBundle load(Path configDirectory) throws IOException {
Files.createDirectories(configDirectory);
@@ -17,6 +18,9 @@ public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog) {
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
return new ConfigBundle(config, shopCatalog);
Map<String, Object> contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml());
ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts());
return new ConfigBundle(config, shopCatalog, contractCatalog);
}
}
@@ -9,6 +9,7 @@ public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop,
HudConfig hud,
PermissionConfig permissions
@@ -19,6 +20,7 @@ public record SoulStealConfig(
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker");
Map<String, Object> contractsSection = YamlConfigHelper.section(root, "contracts");
Map<String, Object> shopSection = YamlConfigHelper.section(root, "shop");
Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud");
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
@@ -69,6 +71,15 @@ public record SoulStealConfig(
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
);
ContractConfig contractConfig = new ContractConfig(
YamlConfigHelper.bool(contractsSection, "enabled", true),
YamlConfigHelper.bool(contractsSection, "auto_claim", true),
new ContractHudConfig(
YamlConfigHelper.bool(contractsSection, "hud_enabled", true),
YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract")
)
);
ShopUiConfig shopUiConfig = new ShopUiConfig(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)),
@@ -106,7 +117,7 @@ public record SoulStealConfig(
YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard")
);
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, shopUiConfig, hudConfig, permissionConfig);
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, contractConfig, shopUiConfig, hudConfig, permissionConfig);
}
public static String defaultYaml() {
@@ -142,6 +153,12 @@ public record SoulStealConfig(
update_interval_ticks: 20
expire_if_target_offline: false
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
shop:
title: "Soul Shop"
rows: 3
@@ -219,6 +236,12 @@ public record SoulStealConfig(
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
}
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud) {
}
public record ContractHudConfig(boolean enabled, String title) {
}
public record ShopUiConfig(
String title,
int rows,
@@ -0,0 +1,113 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig;
import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Parsed contract catalog loaded from `catalog.yml`.
*
* <p>Contracts are grouped by type in the GUI, but remain flat in the config so each entry can be
* addressed by its own id.</p>
*/
public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) {
public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) {
List<ContractDefinition> contracts = new ArrayList<>();
Object rawContracts = root.get("contracts");
if (rawContracts instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract);
}
} else if (rawContracts instanceof Map<?, ?> rawSections) {
for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) {
if (sectionEntry.getValue() instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract);
}
}
}
}
return new ContractCatalog(config.enabled(), config.autoClaim(), config.hud().enabled(), config.hud().title(), contracts);
}
public Optional<ContractDefinition> contract(String id) {
return contracts.stream().filter(contract -> contract.id().equalsIgnoreCase(id)).findFirst();
}
public List<ContractDefinition> contractsOfType(ContractType type) {
return contracts.stream().filter(contract -> contract.type() == type).toList();
}
public static String defaultYaml() {
return """
contracts:
mining:
- id: "iron_miner"
name: "Mining Contracts"
icon: "minecraft:iron_pickaxe"
type: "mining"
target: "minecraft:iron_ore"
target_name: "Iron Ore"
description: "Mine iron ore to earn souls."
amount: 64
reward: 250
repeatable: true
hunting:
- id: "zombie_hunter"
name: "Zombie Hunter"
icon: "minecraft:zombie_head"
type: "hunting"
target: "minecraft:zombie"
target_name: "Zombie"
description: "Hunt zombies to earn souls."
amount: 20
reward: 200
repeatable: true
""";
}
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
Map<String, Object> converted = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() != null) {
converted.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return converted;
}
private static void addContract(List<ContractDefinition> contracts, Object rawContract) {
if (!(rawContract instanceof Map<?, ?> rawMap)) {
return;
}
Map<String, Object> map = toStringMap(rawMap);
String id = YamlConfigHelper.string(map, "id", "").trim();
if (id.isBlank()) {
return;
}
String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase();
ContractType type;
try {
type = ContractType.valueOf(typeName);
} catch (IllegalArgumentException ignored) {
return;
}
contracts.add(new ContractDefinition(
id,
YamlConfigHelper.string(map, "name", id),
YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"),
type,
YamlConfigHelper.string(map, "target", ""),
YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)),
YamlConfigHelper.string(map, "description", ""),
Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)),
Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)),
YamlConfigHelper.bool(map, "repeatable", true)
));
}
}
@@ -0,0 +1,21 @@
package com.g2806.soulsteal.contract;
/**
* Immutable definition for one contract entry loaded from `catalog.yml`.
*
* <p>The YAML key is the internal id used for selection and persistence, while {@code name} is
* the player-facing label shown in the GUI and HUD.</p>
*/
public record ContractDefinition(
String id,
String name,
String iconItemId,
ContractType type,
String targetId,
String targetName,
String description,
long amountRequired,
long reward,
boolean repeatable
) {
}
@@ -0,0 +1,297 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/**
* Builds the server-side contract browser and dispatches click handling for contract selection.
*
* <p>The contract UI follows the same chest-based presentation style as the shop GUI so players
* can scan categories, read contract details, and select an active objective without client-side
* mods.</p>
*/
public final class ContractGuiService {
private static final int PAGE_ROWS = 6;
private static final int ITEM_SLOT_COUNT = 45;
private static final int SLOT_HOME = 45;
private static final int SLOT_PREVIOUS = 46;
private static final int SLOT_INFO = 49;
private static final int SLOT_CLEAR = 52;
private static final int SLOT_NEXT = 53;
private final Supplier<ConfigBundle> bundleSupplier;
private final ContractService contractService;
private final RewardService rewardService;
private final SoulService soulService;
public ContractGuiService(Supplier<ConfigBundle> bundleSupplier, ContractService contractService, RewardService rewardService, SoulService soulService) {
this.bundleSupplier = bundleSupplier;
this.contractService = contractService;
this.rewardService = rewardService;
this.soulService = soulService;
}
public void openContracts(ServerPlayerEntity player) {
openHome(player, 0);
}
public void openHome(ServerPlayerEntity player, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new HomeView(page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public void openCategory(ServerPlayerEntity player, String categoryKey, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new CategoryView(categoryKey, page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public SimpleInventory createInventory(ServerPlayerEntity player, View view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView);
};
}
public void handleClick(ServerPlayerEntity player, View view, int slotIndex) {
if (view instanceof HomeView homeView) {
handleHomeClick(player, homeView, slotIndex);
} else if (view instanceof CategoryView categoryView) {
handleCategoryClick(player, categoryView, slotIndex);
}
}
public ContractDefinition selected(ServerPlayerEntity player) {
return contractService.selectedContract(player.getUuid()).orElse(null);
}
public long progress(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return selected == null ? 0L : contractService.progress(player.getUuid(), selected.id());
}
public long progress(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.progress(player.getUuid(), contract.id());
}
public boolean select(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.selectContract(player, contract.id());
}
public void clearSelection(ServerPlayerEntity player) {
contractService.clearContract(player.getUuid());
}
private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> mining = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
List<ContractDefinition> hunting = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
inventory.setStack(0, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts"));
inventory.setStack(1, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts"));
inventory.setStack(SLOT_INFO, createHomeInfoButton(player));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
return inventory;
}
private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) {
inventory.setStack(index, createContractStack(player, contracts.get(index)));
}
inventory.setStack(SLOT_HOME, createBackButton());
inventory.setStack(SLOT_PREVIOUS, createPageButton(view.page(), totalPages(player, view.categoryKey()), true));
inventory.setStack(SLOT_INFO, createCategoryInfoButton(player, view.categoryKey()));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
inventory.setStack(SLOT_NEXT, createPageButton(view.page(), totalPages(player, view.categoryKey()), false));
return inventory;
}
private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) {
if (slotIndex == 0) {
openCategory(player, "mining", 0);
} else if (slotIndex == 1) {
openCategory(player, "hunting", 0);
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openHome(player, view.page());
}
}
private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) {
if (slotIndex < ITEM_SLOT_COUNT) {
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
if (slotIndex >= contracts.size()) {
return;
}
ContractDefinition contract = contracts.get(slotIndex);
if (select(player, contract)) {
player.sendMessage(net.minecraft.text.Text.literal("Selected contract: " + contract.name()).formatted(Formatting.GREEN), false);
}
openCategory(player, view.categoryKey(), view.page());
return;
}
if (slotIndex == SLOT_HOME) {
openContracts(player);
} else if (slotIndex == SLOT_PREVIOUS) {
openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1));
} else if (slotIndex == SLOT_NEXT) {
openCategory(player, view.categoryKey(), Math.min(totalPages(player, view.categoryKey()) - 1, view.page() + 1));
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openCategory(player, view.categoryKey(), view.page());
}
}
private ItemStack createContractStack(ServerPlayerEntity player, ContractDefinition contract) {
long progress = progress(player, contract);
List<Text> lore = new ArrayList<>();
if (!contract.description().isBlank()) {
lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY));
}
lore.add(Text.literal("Target: " + contract.targetName()).formatted(Formatting.AQUA));
lore.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GOLD));
lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
ContractDefinition selected = selected(player);
if (selected != null && selected.id().equalsIgnoreCase(contract.id())) {
lore.add(Text.literal("Selected").formatted(Formatting.AQUA));
}
return createPreviewStack(contract.iconItemId(), contract.name(), lore);
}
private ItemStack createCategoryButton(String name, String iconItemId, int count, String description) {
return createPreviewStack(iconItemId, name, List.of(
Text.literal(description).formatted(Formatting.GRAY),
Text.literal("Contracts: " + count).formatted(Formatting.AQUA)
));
}
private ItemStack createHomeInfoButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
List<Text> lore = new ArrayList<>();
lore.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
lore.add(Text.literal("Selected: " + (selected == null ? "None" : selected.name())).formatted(Formatting.AQUA));
if (selected != null) {
lore.add(Text.literal("Progress: " + progress(player, selected) + "/" + selected.amountRequired()).formatted(Formatting.GRAY));
}
return createPreviewStack("minecraft:nether_star", "Contract Browser", lore);
}
private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = contractsFor(player, categoryKey);
return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of(
Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA),
Text.literal("Selected: " + (selected(player) == null ? "None" : selected(player).name())).formatted(Formatting.GOLD)
));
}
private ItemStack createBackButton() {
return createPreviewStack("minecraft:barrier", "Back", List.of(Text.literal("Return to the contract browser.").formatted(Formatting.GRAY)));
}
private ItemStack createClearButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return createPreviewStack("minecraft:redstone_torch", "Clear Selected", List.of(
Text.literal(selected == null ? "No contract selected." : "Clear: " + selected.name()).formatted(Formatting.GRAY),
Text.literal("Remove your active contract.").formatted(Formatting.DARK_GRAY)
));
}
private ItemStack createPageButton(int page, int totalPages, boolean previous) {
boolean available = previous ? page > 0 : page < totalPages - 1;
String label = previous ? "Previous Page" : "Next Page";
return createPreviewStack("minecraft:arrow", label, List.of(
Text.literal("Page " + (page + 1) + " of " + totalPages).formatted(Formatting.GRAY),
Text.literal(available ? "Click to switch pages." : "No more pages in this direction.").formatted(available ? Formatting.AQUA : Formatting.DARK_GRAY)
));
}
private List<ContractDefinition> contractsFor(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = switch (categoryKey) {
case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
default -> List.of();
};
return contracts.stream()
.filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id()))
.toList();
}
private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) {
int perPage = Math.max(1, ITEM_SLOT_COUNT);
int totalPages = Math.max(1, (int) Math.ceil(contracts.size() / (double) perPage));
int actualPage = Math.max(0, Math.min(totalPages - 1, page));
int from = actualPage * perPage;
int to = Math.min(contracts.size(), from + perPage);
return contracts.subList(from, to);
}
private int totalPages(ServerPlayerEntity player, String categoryKey) {
return Math.max(1, (int) Math.ceil(contractsFor(player, categoryKey).size() / (double) ITEM_SLOT_COUNT));
}
private String categoryLabel(String categoryKey) {
return switch (categoryKey) {
case "mining" -> "Mining Contracts";
case "hunting" -> "Hunting Contracts";
default -> "Contracts";
};
}
private SimpleInventory filledInventory(int rows) {
SimpleInventory inventory = new SimpleInventory(rows * 9);
ItemStack filler = createPreviewStack(bundleSupplier.get().config().shop().fillerItemId(), " ", List.of());
filler.remove(DataComponentTypes.LORE);
for (int slot = 0; slot < inventory.size(); slot++) {
inventory.setStack(slot, filler.copy());
}
return inventory;
}
private ItemStack createPreviewStack(String itemId, String name, List<Text> lore) {
ItemStack stack = rewardService.createPreviewStack(itemId, name, lore);
return stack;
}
private record HomeView(int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
private record CategoryView(String categoryKey, int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
public sealed interface View permits HomeView, CategoryView {
int rows();
}
}
@@ -0,0 +1,64 @@
package com.g2806.soulsteal.contract;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.screen.ScreenHandler;
import net.minecraft.screen.ScreenHandlerType;
import net.minecraft.screen.slot.Slot;
import net.minecraft.screen.slot.SlotActionType;
import net.minecraft.server.network.ServerPlayerEntity;
/**
* Generic container handler that turns contract GUI clicks into selection actions.
*/
public final class ContractScreenHandler extends ScreenHandler {
private final SimpleInventory inventory;
private final ContractGuiService guiService;
private final ContractGuiService.View view;
public ContractScreenHandler(int syncId, PlayerInventory playerInventory, ContractGuiService guiService, ContractGuiService.View view) {
super(view.rows() == 3 ? ScreenHandlerType.GENERIC_9X3 : ScreenHandlerType.GENERIC_9X6, syncId);
this.guiService = guiService;
this.view = view;
this.inventory = guiService.createInventory((ServerPlayerEntity) playerInventory.player, view);
int rows = view.rows();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < 9; column++) {
int slotIndex = row * 9 + column;
this.addSlot(new Slot(inventory, slotIndex, 8 + column * 18, 18 + row * 18));
}
}
int playerInventoryY = 18 + rows * 18 + 14;
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column + row * 9 + 9, 8 + column * 18, playerInventoryY + row * 18));
}
}
int hotbarY = playerInventoryY + 58;
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column, 8 + column * 18, hotbarY));
}
}
@Override
public ItemStack quickMove(PlayerEntity player, int slot) {
return ItemStack.EMPTY;
}
@Override
public boolean canUse(PlayerEntity player) {
return true;
}
@Override
public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) {
if (player instanceof ServerPlayerEntity serverPlayer && slotIndex >= 0 && slotIndex < inventory.size()) {
guiService.handleClick(serverPlayer, view, slotIndex);
}
}
}
@@ -0,0 +1,6 @@
package com.g2806.soulsteal.contract;
public enum ContractType {
MINING,
HUNTING
}
@@ -22,6 +22,9 @@ public final class SoulStealData {
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, String> selectedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractProgress = new HashMap<>();
private Map<String, Set<String>> completedContracts = new HashMap<>();
public SoulStealData normalize() {
if (souls == null) {
@@ -48,38 +51,99 @@ public final class SoulStealData {
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
}
if (selectedContracts == null) {
selectedContracts = new HashMap<>();
}
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
if (completedContracts == null) {
completedContracts = new HashMap<>();
}
return this;
}
/**
* Returns the persistent soul balance table keyed by player UUID string.
*
* @return mutable soul balance map
*/
public Map<String, Long> souls() {
return souls;
}
/**
* Returns the active bounty list.
*
* @return mutable list of active bounties
*/
public List<StoredBounty> activeBounties() {
return activeBounties;
}
/**
* Returns the set of shop entries unlocked per player.
*
* @return mutable unlock table
*/
public Map<String, Set<String>> unlockedEntries() {
return unlockedEntries;
}
/**
* Returns the per-entry purchase cooldown table.
*
* @return mutable cooldown map
*/
public Map<String, Map<String, Long>> purchaseCooldowns() {
return purchaseCooldowns;
}
/**
* Returns the internal permission fallback table.
*
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
/**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns;
}
/**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames;
}
/**
* Returns the per-player scoreboard visibility table.
*
* @return mutable scoreboard visibility map
*/
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility;
}
public Map<String, String> selectedContracts() {
return selectedContracts;
}
public Map<String, Map<String, Long>> contractProgress() {
return contractProgress;
}
public Map<String, Set<String>> completedContracts() {
return completedContracts;
}
}
@@ -29,6 +29,11 @@ public final class SoulStealDataStore {
this.dataFile = dataDirectory.resolve("soulsteal-data.json");
}
/**
* Loads persistent state from disk, creating a new file if needed.
*
* @throws IOException if the file cannot be read or created
*/
public synchronized void load() throws IOException {
Files.createDirectories(dataDirectory);
if (Files.notExists(dataFile)) {
@@ -43,10 +48,20 @@ public final class SoulStealDataStore {
}
}
/**
* Returns the in-memory persistent data snapshot.
*
* @return mutable data model used by the running server
*/
public synchronized SoulStealData data() {
return data;
}
/**
* Saves the current in-memory state to disk atomically when possible.
*
* @throws IOException if the file cannot be written
*/
public synchronized void save() throws IOException {
Files.createDirectories(dataDirectory);
Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp");
@@ -18,14 +18,29 @@ public record StoredBounty(
long createdAtEpochMillis,
long expiresAtEpochMillis
) {
/**
* Parses the bounty id as a UUID.
*
* @return bounty UUID
*/
public UUID idAsUuid() {
return UUID.fromString(id);
}
/**
* Parses the placer id as a UUID.
*
* @return placer UUID
*/
public UUID placerUuidAsUuid() {
return UUID.fromString(placerUuid);
}
/**
* Parses the target id as a UUID.
*
* @return target UUID
*/
public UUID targetUuidAsUuid() {
return UUID.fromString(targetUuid);
}
@@ -24,6 +24,18 @@ public final class BountyService {
this.soulService = soulService;
}
/**
* Attempts to place a bounty on a target player.
*
* @param placerUuid player paying for the bounty
* @param placerName display name used for messages
* @param targetUuid target player UUID
* @param targetName target display name used for messages
* @param amount bounty value in souls
* @param durationSeconds bounty lifetime in seconds
* @param nowEpochMillis current time used for cooldown and expiry calculations
* @return placement outcome and the created bounty when successful
*/
public PlaceBountyResult placeBounty(
UUID placerUuid,
String placerName,
@@ -86,6 +98,13 @@ public final class BountyService {
return new PlaceBountyResult(true, "Bounty placed successfully.", bounty);
}
/**
* Claims all active bounties on a target after a successful kill.
*
* @param killerUuid player receiving the payout
* @param targetUuid target player whose bounties may be claimed
* @return the combined payout and the list of claimed bounties
*/
public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) {
List<StoredBounty> claimed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -110,6 +129,12 @@ public final class BountyService {
return new ClaimBountyResult(reward, claimed);
}
/**
* Clears all active bounties on a target without paying them out.
*
* @param targetUuid target player UUID
* @return the removed bounty records
*/
public List<StoredBounty> clearForTarget(UUID targetUuid) {
List<StoredBounty> removed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -132,6 +157,12 @@ public final class BountyService {
return removed;
}
/**
* Processes expired bounties and pays the configured survivor reward where applicable.
*
* @param nowEpochMillis current time used to determine expiration
* @return payout records for every expired bounty handled in this pass
*/
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
List<ExpiredBountyPayout> payouts = new ArrayList<>();
@@ -157,15 +188,32 @@ public final class BountyService {
return payouts;
}
/**
* Returns a snapshot of all active bounties.
*
* @return immutable copy of the current active bounty list
*/
public List<StoredBounty> activeBounties() {
return List.copyOf(dataStore.data().activeBounties());
}
/**
* Returns all active bounties for a specific target player.
*
* @param targetUuid target player UUID
* @return bounties currently assigned to that player
*/
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) {
String targetKey = key(targetUuid);
return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList();
}
/**
* Returns the next time the given placer may create another bounty.
*
* @param placerUuid player UUID to inspect
* @return epoch milliseconds when the placement cooldown ends, or {@code 0} if none exists
*/
public long nextPlacementTime(UUID placerUuid) {
return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L);
}
@@ -0,0 +1,123 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.contract.ContractCatalog;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity;
public final class ContractService {
private final Supplier<ContractCatalog> catalogSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
public ContractService(Supplier<ContractCatalog> catalogSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.catalogSupplier = catalogSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
}
public Optional<ContractDefinition> selectedContract(UUID playerUuid) {
String contractId = dataStore.data().selectedContracts().get(key(playerUuid));
if (contractId == null) {
return Optional.empty();
}
return catalogSupplier.get().contract(contractId);
}
public boolean selectContract(ServerPlayerEntity player, String contractId) {
Optional<ContractDefinition> contract = catalogSupplier.get().contract(contractId);
if (contract.isEmpty()) {
return false;
}
if (!contract.get().repeatable() && hasCompletedContract(player.getUuid(), contract.get().id())) {
return false;
}
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
dataStore.data().contractProgress().remove(key(player.getUuid()));
saveQuietly();
return true;
}
public void clearContract(UUID playerUuid) {
dataStore.data().selectedContracts().remove(key(playerUuid));
dataStore.data().contractProgress().remove(key(playerUuid));
saveQuietly();
}
public long progress(UUID playerUuid) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.values().stream().mapToLong(Long::longValue).sum();
}
public long progress(UUID playerUuid, String contractId) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.getOrDefault(contractId, 0L);
}
public boolean hasCompletedContract(UUID playerUuid, String contractId) {
return dataStore.data().completedContracts()
.getOrDefault(key(playerUuid), java.util.Set.of())
.contains(contractId);
}
public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId);
}
public void recordHunting(ServerPlayerEntity player, String entityId) {
record(player, ContractType.HUNTING, entityId);
}
private void record(ServerPlayerEntity player, ContractType type, String targetId) {
Optional<ContractDefinition> selected = selectedContract(player.getUuid());
if (selected.isEmpty() || !catalogSupplier.get().enabled()) {
return;
}
ContractDefinition contract = selected.get();
if (contract.type() != type || !contract.targetId().equalsIgnoreCase(targetId)) {
return;
}
String playerKey = key(player.getUuid());
Map<String, Long> progressMap = dataStore.data().contractProgress().computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>());
long updated = progressMap.getOrDefault(contract.id(), 0L) + 1L;
if (updated >= contract.amountRequired()) {
progressMap.remove(contract.id());
soulService.addSouls(player.getUuid(), contract.reward());
player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+"
+ contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false);
if (!contract.repeatable()) {
dataStore.data().completedContracts()
.computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>())
.add(contract.id());
}
if (!contract.repeatable()) {
dataStore.data().selectedContracts().remove(playerKey);
}
} else {
progressMap.put(contract.id(), updated);
}
saveQuietly();
}
private void saveQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist contract data.", exception);
}
}
private static String key(UUID playerUuid) {
return playerUuid.toString();
}
}
@@ -3,6 +3,7 @@ package com.g2806.soulsteal.service;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -31,6 +32,7 @@ import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */
public final class HudService {
@@ -38,6 +40,7 @@ public final class HudService {
private final SoulStealDataStore dataStore;
private final SoulService soulService;
private final BountyService bountyService;
private final ContractService contractService;
private final Map<UUID, SidebarState> sidebars = new HashMap<>();
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
@@ -45,24 +48,42 @@ public final class HudService {
Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore,
SoulService soulService,
BountyService bountyService
BountyService bountyService,
ContractService contractService
) {
this.configSupplier = configSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
this.bountyService = bountyService;
this.contractService = contractService;
}
/**
* Records player metadata and refreshes their HUD state when they join.
*
* @param player joining player
*/
public void handlePlayerJoin(ServerPlayerEntity player) {
rememberPlayer(player);
refreshPlayerDisplays(player, System.currentTimeMillis());
}
/**
* Clears any per-player HUD state when a player disconnects.
*
* @param player disconnecting player
*/
public void handlePlayerDisconnect(ServerPlayerEntity player) {
clearSidebar(player);
clearBossBar(player);
}
/**
* Refreshes active HUD elements for all online players and trims stale state.
*
* @param server current server instance
* @param nowEpochMillis current time used for countdown calculations
*/
public void tick(MinecraftServer server, long nowEpochMillis) {
Set<UUID> onlinePlayers = new HashSet<>();
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
@@ -81,11 +102,24 @@ public final class HudService {
});
}
/**
* Returns whether the given player's scoreboard sidebar is currently visible.
*
* @param playerUuid player UUID to inspect
* @return current visibility state, falling back to the config default
*/
public boolean isScoreboardVisible(UUID playerUuid) {
return dataStore.data().scoreboardVisibility()
.getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible());
}
/**
* Sets scoreboard visibility for a player and refreshes their sidebar immediately.
*
* @param player player to update
* @param visible requested visibility state
* @return the stored visibility state
*/
public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) {
rememberPlayer(player);
Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible);
@@ -96,10 +130,22 @@ public final class HudService {
return visible;
}
/**
* Flips the scoreboard visibility flag for a player.
*
* @param player player to update
* @return the updated visibility state
*/
public boolean toggleScoreboardVisible(ServerPlayerEntity player) {
return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid()));
}
/**
* Builds a leaderboard page from stored names and balances.
*
* @param requestedPage 1-based page number requested by the caller
* @return the clamped leaderboard page and its entries
*/
public LeaderboardPage leaderboard(int requestedPage) {
Set<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
playerKeys.addAll(dataStore.data().souls().keySet());
@@ -196,19 +242,29 @@ public final class HudService {
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
long remainingSeconds = activeBounties.stream()
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
List<Text> lines = new ArrayList<>();
lines.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
return List.of(
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())),
Text.literal("Bounties: " + activeBounties.size()),
Text.literal("Wanted Value: " + totalValue),
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None"))
);
contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
long progress = contractService.progress(player.getUuid(), contract.id());
lines.add(Text.literal("Contract: " + contract.name()).formatted(Formatting.AQUA));
lines.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GRAY));
});
if (!activeBounties.isEmpty()) {
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
long remainingSeconds = activeBounties.stream()
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
lines.add(Text.literal("Bounties: " + activeBounties.size()).formatted(Formatting.RED));
lines.add(Text.literal("Wanted Value: " + totalValue).formatted(Formatting.GOLD));
lines.add(Text.literal("Wanted Time: " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(Formatting.DARK_RED));
}
return lines;
}
private void clearSidebar(ServerPlayerEntity player) {
@@ -238,7 +294,7 @@ public final class HudService {
return scoreboard.addObjective(
objectiveName,
ScoreboardCriterion.DUMMY,
Text.literal(configSupplier.get().hud().scoreboard().title()),
Text.literal(configSupplier.get().hud().scoreboard().title()).formatted(Formatting.DARK_AQUA),
ScoreboardCriterion.RenderType.INTEGER,
false,
BlankNumberFormat.INSTANCE
@@ -26,6 +26,14 @@ public final class PermissionService {
this.dataStore = dataStore;
}
/**
* Checks whether a command source has a permission node.
*
* @param source permission subject
* @param permission node to check
* @param defaultLevel fallback operator level when no permission backend is available
* @return {@code true} if the source is allowed to use the permission
*/
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) {
return true;
@@ -39,6 +47,14 @@ public final class PermissionService {
return source.getPlayer() == null || defaultLevel <= 0;
}
/**
* Checks whether a source has any permission from the provided set.
*
* @param source permission subject
* @param defaultLevel fallback operator level when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) {
@@ -48,6 +64,14 @@ public final class PermissionService {
return false;
}
/**
* Checks whether a player has a permission node.
*
* @param player permission subject
* @param permission node to check
* @param defaultValue fallback value when no permission backend is available
* @return {@code true} if the player is allowed to use the permission
*/
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) {
if (hasStoredPermission(player.getUuid(), permission)) {
return true;
@@ -61,6 +85,14 @@ public final class PermissionService {
return defaultValue;
}
/**
* Checks whether a player has any permission from the provided set.
*
* @param player permission subject
* @param defaultValue fallback value when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) {
@@ -70,6 +102,15 @@ public final class PermissionService {
return false;
}
/**
* Grants a permission through LuckPerms if available and optionally stores a fallback copy.
*
* @param playerUuid player receiving the permission
* @param permission node to grant or revoke
* @param value desired node value
* @param storeFallback whether to persist the value in Soul Steal's internal store
* @return grant outcome and backend details
*/
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false;
@@ -61,6 +61,13 @@ public final class ShopService {
this.dataStore = dataStore;
}
/**
* Opens the shop UI for either the home view or a specific category.
*
* @param player player to show the UI to
* @param requestedCategoryKey category key to open, or {@code null} for the home view
* @param requestedPage zero-based page index to display
*/
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) {
if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) {
openView(player, resolveHomeView(requestedPage));
@@ -70,6 +77,13 @@ public final class ShopService {
openView(player, resolveCategoryView(requestedCategoryKey, requestedPage));
}
/**
* Builds the backing inventory for a specific shop view.
*
* @param player player who will interact with the inventory
* @param view resolved shop view state
* @return inventory contents appropriate for the supplied view
*/
public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
@@ -78,6 +92,13 @@ public final class ShopService {
};
}
/**
* Dispatches a click inside the shop GUI to the correct view handler.
*
* @param player player interacting with the shop
* @param view current resolved view
* @param slotIndex clicked slot index
*/
public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) {
switch (view) {
case HomeView homeView -> handleHomeClick(player, homeView, slotIndex);
@@ -18,15 +18,35 @@ public final class SoulService {
this.dataStore = dataStore;
}
/**
* Looks up the stored balance for a player UUID.
*
* @param playerUuid player identifier to inspect
* @return the current balance, or the configured starting balance for new players
*/
public long balanceOf(UUID playerUuid) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls());
}
/**
* Checks whether a player currently has at least the requested amount of souls.
*
* @param playerUuid player identifier to inspect
* @param amount amount to compare against
* @return {@code true} when the player has enough souls
*/
public boolean hasSouls(UUID playerUuid, long amount) {
return balanceOf(playerUuid) >= Math.max(0L, amount);
}
/**
* Adds souls to a player's balance and clamps the result to the configured maximum.
*
* @param playerUuid player identifier to update
* @param amount amount to add
* @return the updated balance after clamping
*/
public long addSouls(UUID playerUuid, long amount) {
if (amount <= 0L) {
return balanceOf(playerUuid);
@@ -39,6 +59,13 @@ public final class SoulService {
return updated;
}
/**
* Removes souls from a player's balance and clamps the result at zero.
*
* @param playerUuid player identifier to update
* @param amount amount to remove
* @return the updated balance after clamping
*/
public long removeSouls(UUID playerUuid, long amount) {
if (amount <= 0L) {
return balanceOf(playerUuid);
@@ -50,11 +77,23 @@ public final class SoulService {
return updated;
}
/**
* Sets a player's balance directly, applying the configured upper bound.
*
* @param playerUuid player identifier to update
* @param amount requested new balance
*/
public void setSouls(UUID playerUuid, long amount) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount)));
}
/**
* Applies the configured death penalty to a player.
*
* @param playerUuid player identifier to penalize
* @return the amount removed and the new balance after applying the penalty
*/
public SoulChange applyDeathPenalty(UUID playerUuid) {
long current = balanceOf(playerUuid);
if (current <= 0L) {
@@ -73,6 +112,14 @@ public final class SoulService {
return new SoulChange(-boundedLoss, newBalance);
}
/**
* Transfers souls between two players if the transfer rules allow it.
*
* @param senderUuid source player UUID
* @param receiverUuid destination player UUID
* @param amount amount to transfer
* @return the transfer result, including balances and a human-readable message
*/
public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) {
SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer();
if (!transferConfig.enabled()) {
@@ -5,6 +5,12 @@ public final class DurationFormatter {
private DurationFormatter() {
}
/**
* Formats a duration in seconds into a compact human-readable string.
*
* @param totalSeconds duration to format
* @return formatted duration such as {@code 2h 5m 10s}
*/
public static String formatSeconds(long totalSeconds) {
if (totalSeconds <= 0L) {
return "0s";
@@ -9,22 +9,52 @@ public final class SoulTexts {
private SoulTexts() {
}
/**
* Builds an informational chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text info(String message) {
return prefixed(message, Formatting.GRAY);
}
/**
* Builds a success chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text success(String message) {
return prefixed(message, Formatting.GREEN);
}
/**
* Builds a warning chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text warning(String message) {
return prefixed(message, Formatting.GOLD);
}
/**
* Builds an error chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text error(String message) {
return prefixed(message, Formatting.RED);
}
/**
* Builds highlighted accent text without the standard prefix.
*
* @param message text to accent
* @return formatted text component
*/
public static MutableText accent(String message) {
return Text.literal(message).formatted(Formatting.AQUA);
}