8 Commits

Author SHA1 Message Date
darwincereska 13fd72d304 feat: revamped hud
Release / build-and-release (push) Successful in 1m48s
2026-05-10 15:54:00 -04:00
darwincereska dce135c857 feat: add contract cooldowns, mining target matches, and rank command rewards 2026-05-10 15:44:34 -04:00
darwincereska 7f3bb68719 bump(version): fixed version number
Release / build-and-release (push) Successful in 1m16s
2026-05-09 13:51:58 -04:00
darwincereska fa52df1d39 fix(config): fixed indentation bug
Release / build-and-release (push) Has been cancelled
2026-05-09 13:49:28 -04:00
darwincereska 32165b87e7 feat(ci): add gitea release workflow for tag builds
Release / build-and-release (push) Successful in 2m38s
2026-05-09 11:48:20 -04:00
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
37 changed files with 3006 additions and 781 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Build jar
run: ./gradlew build
- name: Find release artifact
id: artifact
shell: bash
run: |
set -euo pipefail
jar=$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' | head -n 1)
if [ -z "${jar}" ]; then
echo "No jar artifact found in build/libs" >&2
exit 1
fi
echo "path=${jar}" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$jar")" >> "$GITHUB_OUTPUT"
- name: Create Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "Missing GITEA_TOKEN secret" >&2
exit 1
fi
api="${GITHUB_SERVER_URL}/api/v1"
repo="${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}"
release_name="${tag}"
jar_path="${{ steps.artifact.outputs.path }}"
jar_name="${{ steps.artifact.outputs.name }}"
payload=$(jq -n \
--arg tag_name "$tag" \
--arg name "$release_name" \
--arg body "Automated release for $tag" \
'{tag_name:$tag_name,name:$name,body:$body,draft:false,prerelease:false}')
release=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "$payload" \
"${api}/repos/${repo}/releases")
release_id=$(printf '%s' "$release" | jq -r '.id')
upload_url="${api}/repos/${repo}/releases/${release_id}/assets?name=${jar_name}"
curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${jar_path}" \
"${upload_url}"
+1
View File
@@ -2,6 +2,7 @@
.gradle/
build/
out/
logs/
# Loom / Mod Dev Gradle caches
.loom-cache/
+18 -11
View File
@@ -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
@@ -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
@@ -25,15 +27,20 @@ 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 <player> <amount> [durationSeconds]` | All players | Places a timed bounty on another player. |
| `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. |
| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. |
| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. |
| `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. |
| `/souls set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. |
| `/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. |
## Configuration
| File | Purpose |
| --- | --- |
| `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. |
| `config/soulsteal/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. |
| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. |
| `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. |
+21 -13
View File
@@ -20,15 +20,19 @@ repositories {
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}"
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}"
testImplementation platform("org.junit:junit-bom:5.11.4")
testImplementation "org.junit.jupiter:junit-jupiter"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
processResources {
inputs.property 'version', project.version
@@ -38,9 +42,13 @@ processResources {
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
test {
useJUnitPlatform()
}
java {
withSourcesJar()
@@ -65,4 +73,4 @@ publishing {
repositories {
}
}
}
+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.5.0
maven_group=com.g2806.soulsteal
archives_base_name=soul-steal
@@ -1,196 +1,330 @@
package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.service.TrackerCompassService;
import com.g2806.soulsteal.util.SoulTexts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Entrypoint for the Soul Steal mod.
*
* <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
* single bootstrap location for lifecycle setup and shared constants.</p>
*/
public final class SoulStealMod implements ModInitializer {
public static final String MOD_ID = "soulsteal";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private Path configDirectory;
private ConfigBundle configBundle;
private SoulStealDataStore dataStore;
private SoulService soulService;
private PermissionService permissionService;
private BountyService bountyService;
private RewardService rewardService;
private TrackerCompassService trackerCompassService;
private ShopService shopService;
private HudService hudService;
@Override
public void onInitialize() {
LOGGER.info("Initializing Soul Steal");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
try {
configBundle = ConfigBundle.load(configDirectory);
dataStore = new SoulStealDataStore(configDirectory);
dataStore.load();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
}
permissionService = new PermissionService(dataStore);
soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService);
rewardService = new RewardService(permissionService, soulService);
trackerCompassService = new TrackerCompassService(this::config);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> {
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) {
onPlayerKilledOtherPlayer(killer, victim);
}
});
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) {
onPlayerDeath(player, damageSource);
}
});
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
}
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
long reward = config().economy().killReward();
if (reward > 0L) {
soulService.addSouls(killer.getUuid(), reward);
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
}
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
if (bountyClaim.claimedAny()) {
MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
}
trackerCompassService.giveTrackerCompass(killer, victim);
}
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid());
if (penalty.delta() < 0L) {
player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false);
}
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
if (!removedBounties.isEmpty()) {
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
}
}
}
private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
if (server.getTicks() % 20 != 0) {
return;
}
long now = System.currentTimeMillis();
for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) {
ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) {
target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false);
}
if (payout.reward() > 0L) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false);
}
}
hudService.tick(server, now);
}
public boolean reloadConfiguration() {
try {
configBundle = ConfigBundle.load(configDirectory);
return true;
} catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false;
}
}
private void saveData() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
public ConfigBundle bundle() {
return configBundle;
}
public SoulStealConfig config() {
return configBundle.config();
}
public SoulService soulService() {
return soulService;
}
public PermissionService permissionService() {
return permissionService;
}
public BountyService bountyService() {
return bountyService;
}
public RewardService rewardService() {
return rewardService;
}
public TrackerCompassService trackerCompassService() {
return trackerCompassService;
}
public ShopService shopService() {
return shopService;
}
public HudService hudService() {
return hudService;
}
}
package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.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;
import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.service.TrackerCompassService;
import com.g2806.soulsteal.util.SoulTexts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.item.BlockItem;
import net.minecraft.item.ItemStack;
import net.minecraft.util.math.BlockPos;
import net.minecraft.registry.Registries;
import net.minecraft.util.ActionResult;
import net.minecraft.util.math.Direction;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Entrypoint for the Soul Steal mod.
*
* <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
* single bootstrap location for lifecycle setup and shared constants.</p>
*/
public final class SoulStealMod implements ModInitializer {
public static final String MOD_ID = "soulsteal";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private Path configDirectory;
private ConfigBundle configBundle;
private SoulStealDataStore dataStore;
private SoulService soulService;
private PermissionService permissionService;
private BountyService bountyService;
private 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");
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
try {
configBundle = ConfigBundle.load(configDirectory);
dataStore = new SoulStealDataStore(configDirectory);
dataStore.load();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
}
permissionService = new PermissionService(dataStore, () -> this.config().permissions());
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, contractService);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> {
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) {
onPlayerKilledOtherPlayer(killer, victim);
}
});
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (entity instanceof ServerPlayerEntity player) {
onPlayerDeath(player, damageSource);
return;
}
if (damageSource.getAttacker() instanceof ServerPlayerEntity killer) {
contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString());
}
});
UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
if (!(player instanceof ServerPlayerEntity serverPlayer)) {
return ActionResult.PASS;
}
ItemStack stack = serverPlayer.getStackInHand(hand);
if (!(stack.getItem() instanceof BlockItem blockItem)) {
return ActionResult.PASS;
}
// Match block placement by the item the player is holding, not the broken state.
// That lets us mark player-placed ore blocks even when silk touch preserves the block.
if (!contractService.matchesMiningTarget(blockItem.getBlock())) {
return ActionResult.PASS;
}
String key = blockKey(world, placedBlockPos(hitResult.getBlockPos(), hitResult.getSide()));
dataStore.data().playerPlacedMiningTargets().add(key);
saveDataQuietly();
return ActionResult.PASS;
});
PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
if (player instanceof ServerPlayerEntity serverPlayer) {
String key = blockKey(world, pos);
// Ignore blocks we recorded as player-placed targets; those should not advance mining.
if (dataStore.data().playerPlacedMiningTargets().remove(key)) {
saveDataQuietly();
return;
}
contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
}
});
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
}
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
long reward = config().economy().killReward();
if (reward > 0L) {
soulService.addSouls(killer.getUuid(), reward);
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
}
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
if (bountyClaim.claimedAny()) {
MinecraftServer server = killer.getCommandSource().getServer();
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
}
trackerCompassService.giveTrackerCompass(killer, victim);
}
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid());
if (penalty.delta() < 0L) {
player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false);
}
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
if (!removedBounties.isEmpty()) {
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
}
}
}
private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
if (server.getTicks() % 20 != 0) {
return;
}
long now = System.currentTimeMillis();
for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) {
ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) {
target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false);
}
if (payout.reward() > 0L) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false);
}
}
hudService.tick(server, now);
}
/**
* 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;
} catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false;
}
}
private void saveData() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
private void saveDataQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
private static String blockKey(net.minecraft.world.World world, net.minecraft.util.math.BlockPos pos) {
return world.getRegistryKey().getValue() + "|" + pos;
}
/**
* Returns the block position where a placement will land for a normal face click.
*
* <p>We use the placed block position, not the clicked block position, so player-placed
* contract targets are marked correctly even when the item came from silk touch.</p>
*/
static BlockPos placedBlockPos(BlockPos clickedPos, Direction side) {
return clickedPos.offset(side);
}
/**
* 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,7 +1,8 @@
package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.StoredBounty;
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;
import com.g2806.soulsteal.service.SoulService;
@@ -22,84 +23,126 @@ import static net.minecraft.server.command.CommandManager.literal;
/** Registers the public command surface for Soul Steal. */
public final class SoulCommandRegistrar {
private SoulCommandRegistrar() {
}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) {
dispatcher.register(buildRoot("souls", mod));
dispatcher.register(buildRoot("soul", mod));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) {
return literal(rootName)
.executes(context -> showOwnBalance(context, mod))
.then(literal("balance")
.executes(context -> showOwnBalance(context, mod))
.then(argument("player", EntityArgumentType.player())
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().balanceOthersNode()))
.executes(context -> showTargetBalance(context, mod))))
.then(literal("pay")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> transferSouls(context, mod)))))
.then(literal("shop")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.executes(context -> openShop(context, mod, null))
.then(argument("category", StringArgumentType.word())
.executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category")))))
.then(literal("bounty")
.requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0))
.then(literal("place")
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds()))
.then(argument("durationSeconds", LongArgumentType.longArg(1L))
.executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds")))))))
.then(literal("list")
.executes(context -> listBounties(context, mod, null))
.then(argument("player", EntityArgumentType.player())
.executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player"))))))
.then(literal("scoreboard")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode()))
.executes(context -> showScoreboardStatus(context, mod))
.then(literal("toggle")
.executes(context -> toggleScoreboard(context, mod)))
.then(literal("on")
.executes(context -> setScoreboardVisibility(context, mod, true)))
.then(literal("off")
.executes(context -> setScoreboardVisibility(context, mod, false))))
.then(literal("top")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode()))
.executes(context -> showLeaderboard(context, mod, 1))
.then(argument("page", IntegerArgumentType.integer(1))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page")))))
.then(literal("reload")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().reloadNode()))
.executes(context -> reload(context, mod)))
.then(literal("set")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().setNode()))
.then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(0L))
.executes(context -> setBalance(context, mod)))))
.then(literal("add")
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(),
mod.config().permissions().addNode()))
.then(argument("player", EntityArgumentType.player())
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())
// 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")))))))
.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 <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(),
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 +186,14 @@ public final class SoulCommandRegistrar {
return 1;
}
private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
boolean visible = mod.hudService().isScoreboardVisible(player.getUuid());
String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled.";
if (!mod.config().hud().scoreboard().enabled()) {
message += " The server-wide HUD toggle is disabled in config.";
}
private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
boolean visible = mod.hudService().isScoreboardVisible(player.getUuid());
String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled.";
// 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 +223,71 @@ public final class SoulCommandRegistrar {
return 1;
}
private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) {
HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page);
if (leaderboardPage.entries().isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false);
return 1;
}
int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize());
context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false);
for (int index = 0; index < leaderboardPage.entries().size(); index++) {
HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index);
int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1;
context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false);
}
return 1;
}
private static int placeBounty(CommandContext<ServerCommandSource> context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity placer = context.getSource().getPlayerOrThrow();
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player");
long amount = LongArgumentType.getLong(context, "amount");
private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) {
HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page);
if (leaderboardPage.entries().isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false);
return 1;
}
int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize());
context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false);
for (int index = 0; index < leaderboardPage.entries().size(); index++) {
HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index);
// 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");
long amount = LongArgumentType.getLong(context, "amount");
BountyService.PlaceBountyResult result = mod.bountyService().placeBounty(
placer.getUuid(),
@@ -228,12 +316,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<ServerCommandSource> context, SoulStealMod mod) {
@@ -269,4 +358,4 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true);
return 1;
}
}
}
@@ -1,22 +1,26 @@
package com.g2806.soulsteal.config;
import com.g2806.soulsteal.shop.ShopCatalog;
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);
Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml());
SoulStealConfig config = SoulStealConfig.fromMap(configMap);
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop());
return new ConfigBundle(config, shopCatalog);
}
}
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop(), config.permissions());
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,16 +9,18 @@ public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop,
HudConfig hud,
PermissionConfig permissions
) {
PermissionConfig permissions
) {
public static SoulStealConfig fromMap(Map<String, Object> root) {
Map<String, Object> economySection = YamlConfigHelper.section(root, "economy");
Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty");
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker");
Map<String, Object> 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,16 @@ 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")
),
Math.max(0L, YamlConfigHelper.longValue(contractsSection, "default_repeat_cooldown_seconds", 0L))
);
ShopUiConfig shopUiConfig = new ShopUiConfig(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)),
@@ -93,87 +105,98 @@ public record SoulStealConfig(
)
);
PermissionConfig permissionConfig = new PermissionConfig(
YamlConfigHelper.string(permissionsSection, "admin_node", "soulsteal.admin"),
YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"),
YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"),
YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"),
YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"),
YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"),
YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"),
YamlConfigHelper.string(permissionsSection, "take_node", "soulsteal.admin.balance.take"),
YamlConfigHelper.string(permissionsSection, "scoreboard_node", "soulsteal.scoreboard"),
PermissionConfig permissionConfig = new PermissionConfig(
YamlConfigHelper.string(permissionsSection, "admin_node", "soulsteal.admin"),
YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"),
YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"),
YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"),
YamlConfigHelper.bool(permissionsSection, "luckperms_enabled", true),
YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"),
YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"),
YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"),
YamlConfigHelper.string(permissionsSection, "take_node", "soulsteal.admin.balance.take"),
YamlConfigHelper.string(permissionsSection, "scoreboard_node", "soulsteal.scoreboard"),
YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard")
);
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, shopUiConfig, hudConfig, permissionConfig);
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, contractConfig, shopUiConfig, hudConfig, permissionConfig);
}
public static String defaultYaml() {
return """
economy:
starting_souls: 0
max_souls: 1000000
kill_reward: 25
death_penalty:
flat: 15
percent: 0.10
minimum: 5
maximum: 100
transfer:
enabled: true
minimum: 1
economy:
starting_souls: 0
max_souls: 1000000
kill_reward: 25
death_penalty:
flat: 15
percent: 0.10
minimum: 5
maximum: 100
transfer:
enabled: true
minimum: 1
bounties:
enabled: true
min_value: 25
max_value: 10000
default_duration_seconds: 7200
min_duration_seconds: 600
max_duration_seconds: 86400
survivor_reward_percent: 0.50
placement_cooldown_seconds: 60
max_active_per_target: 5
max_active_per_placer: 3
bounties:
enabled: true
min_value: 25
max_value: 10000
default_duration_seconds: 7200
min_duration_seconds: 600
max_duration_seconds: 86400
survivor_reward_percent: 0.50
placement_cooldown_seconds: 60
max_active_per_target: 5
max_active_per_placer: 3
tracker:
enabled: true
duration_seconds: 900
update_interval_ticks: 20
expire_if_target_offline: false
tracker:
enabled: true
duration_seconds: 900
update_interval_ticks: 20
expire_if_target_offline: false
shop:
title: "Soul Shop"
rows: 3
filler_item: "minecraft:black_stained_glass_pane"
default_purchase_cooldown_seconds: 0
enable_custom_amount_selector: true
default_max_custom_amount: 64
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
default_repeat_cooldown_seconds: 0
hud:
scoreboard:
enabled: true
default_visible: false
title: "Soul HUD"
bounty_bossbar:
enabled: true
title: "Bounty on You"
leaderboard:
page_size: 10
shop:
title: "Soul Shop"
rows: 3
filler_item: "minecraft:light_gray_stained_glass_pane"
default_purchase_cooldown_seconds: 0
enable_custom_amount_selector: true
default_max_custom_amount: 64
permissions:
# soulsteal.admin grants every admin-only action below.
admin_node: "soulsteal.admin"
reload_node: "soulsteal.admin.reload"
shop_node: "soulsteal.shop"
bounty_node: "soulsteal.bounty"
balance_others_node: "soulsteal.admin.balance.others"
set_node: "soulsteal.admin.balance.set"
add_node: "soulsteal.admin.balance.add"
take_node: "soulsteal.admin.balance.take"
scoreboard_node: "soulsteal.scoreboard"
leaderboard_node: "soulsteal.leaderboard"
""";
hud:
scoreboard:
enabled: true
default_visible: false
title: "Soul HUD"
bounty_bossbar:
enabled: true
title: "Bounty on You"
leaderboard:
page_size: 10
permissions:
# soulsteal.admin grants every admin-only action below.
admin_node: "soulsteal.admin"
reload_node: "soulsteal.admin.reload"
shop_node: "soulsteal.shop"
bounty_node: "soulsteal.bounty"
luckperms_enabled: true
balance_others_node: "soulsteal.admin.balance.others"
set_node: "soulsteal.admin.balance.set"
add_node: "soulsteal.admin.balance.add"
take_node: "soulsteal.admin.balance.take"
scoreboard_node: "soulsteal.scoreboard"
leaderboard_node: "soulsteal.leaderboard"
""";
}
private static double clampPercent(double value) {
@@ -181,7 +204,7 @@ public record SoulStealConfig(
}
private static int clampRows(int rows) {
return Math.max(2, Math.min(6, rows));
return Math.max(2, Math.min(6, rows));
}
public record EconomyConfig(long startingSouls, long maxSouls, long killReward, DeathPenaltyConfig deathPenalty, TransferConfig transfer) {
@@ -219,6 +242,12 @@ public record SoulStealConfig(
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
}
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud, long defaultRepeatCooldownSeconds) {
}
public record ContractHudConfig(boolean enabled, String title) {
}
public record ShopUiConfig(
String title,
int rows,
@@ -241,17 +270,18 @@ public record SoulStealConfig(
public record LeaderboardConfig(int pageSize) {
}
public record PermissionConfig(
String adminNode,
String reloadNode,
String shopNode,
String bountyNode,
String balanceOthersNode,
String setNode,
String addNode,
String takeNode,
public record PermissionConfig(
String adminNode,
String reloadNode,
String shopNode,
String bountyNode,
boolean luckpermsEnabled,
String balanceOthersNode,
String setNode,
String addNode,
String takeNode,
String scoreboardNode,
String leaderboardNode
) {
}
}
}
@@ -0,0 +1,139 @@
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, config);
}
} 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, config);
}
}
}
}
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"
targets:
- "minecraft:iron_ore"
- "minecraft:deepslate_iron_ore"
target_name: "Iron Ore"
amount: 20
reward: 200
repeatable: true
cooldown: 10
matches:
- "Iron Ore"
- "Deepslate Iron Ore"
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, ContractConfig config) {
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;
}
java.util.List<String> targets = YamlConfigHelper.stringList(map, "targets");
if (targets.isEmpty()) {
// Backward compatibility: allow the old single-target field for existing catalogs.
String single = YamlConfigHelper.string(map, "target", "").trim();
if (!single.isBlank()) {
targets = java.util.List.of(single);
}
}
targets = targets.stream().map(String::trim).filter(target -> !target.isBlank()).distinct().toList();
java.util.List<String> matches = YamlConfigHelper.stringList(map, "matches")
.stream()
.map(String::trim)
.filter(match -> !match.isBlank())
.distinct()
.toList();
long cooldown = Math.max(0L, YamlConfigHelper.longValue(map, "cooldown", YamlConfigHelper.longValue(map, "cooldown_seconds", config.defaultRepeatCooldownSeconds())));
contracts.add(new ContractDefinition(
id,
YamlConfigHelper.string(map, "name", id),
YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"),
type,
targets,
matches,
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),
cooldown
));
}
}
@@ -0,0 +1,46 @@
package com.g2806.soulsteal.contract;
import java.util.List;
import java.util.stream.Collectors;
/**
* 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,
List<String> targetIds,
List<String> displayMatches,
String targetName,
String description,
long amountRequired,
long reward,
boolean repeatable,
long cooldownSeconds
) {
public String primaryTarget() {
return targetIds == null || targetIds.isEmpty() ? "" : targetIds.get(0);
}
public String targetSummary() {
if (targetIds == null || targetIds.isEmpty()) {
return targetName;
}
if (targetIds.size() == 1) {
return targetName;
}
return targetName + " (" + targetIds.size() + " targets)";
}
public String displayMatchesSummary() {
if (displayMatches == null || displayMatches.isEmpty()) {
return "";
}
return displayMatches.stream().collect(Collectors.joining(", "));
}
}
@@ -0,0 +1,303 @@
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.targetSummary()).formatted(Formatting.AQUA));
if (contract.displayMatches() != null && !contract.displayMatches().isEmpty()) {
lore.add(Text.literal("Matches: " + contract.displayMatchesSummary()).formatted(Formatting.DARK_AQUA));
}
if (contract.cooldownSeconds() > 0L) {
lore.add(Text.literal("Cooldown: " + DurationFormatter.formatSeconds(contract.cooldownSeconds())).formatted(Formatting.DARK_GRAY));
}
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 -> contractService.isContractAvailable(player.getUuid(), contract))
.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
}
@@ -18,12 +18,18 @@ public final class SoulStealData {
private List<StoredBounty> activeBounties = new ArrayList<>();
private Map<String, Set<String>> unlockedEntries = new HashMap<>();
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
private Map<String, Integer> grantedRankPriorities = new HashMap<>();
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<>();
private Map<String, Map<String, Long>> contractCooldowns = new HashMap<>();
private Set<String> playerPlacedMiningTargets = new HashSet<>();
public SoulStealData normalize() {
public SoulStealData normalize() {
if (souls == null) {
souls = new HashMap<>();
}
@@ -36,50 +42,132 @@ public final class SoulStealData {
if (purchaseCooldowns == null) {
purchaseCooldowns = new HashMap<>();
}
if (grantedPermissions == null) {
grantedPermissions = new HashMap<>();
}
if (grantedPermissions == null) {
grantedPermissions = new HashMap<>();
}
if (grantedRankPriorities == null) {
grantedRankPriorities = new HashMap<>();
}
if (bountyPlacementCooldowns == null) {
bountyPlacementCooldowns = new HashMap<>();
}
if (playerNames == null) {
playerNames = new HashMap<>();
}
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
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;
}
public Map<String, Long> souls() {
if (contractCooldowns == null) {
contractCooldowns = new HashMap<>();
}
if (playerPlacedMiningTargets == null) {
playerPlacedMiningTargets = new HashSet<>();
}
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;
}
public List<StoredBounty> activeBounties() {
}
/**
* Returns the active bounty list.
*
* @return mutable list of active bounties
*/
public List<StoredBounty> activeBounties() {
return activeBounties;
}
public Map<String, Set<String>> unlockedEntries() {
}
/**
* Returns the set of shop entries unlocked per player.
*
* @return mutable unlock table
*/
public Map<String, Set<String>> unlockedEntries() {
return unlockedEntries;
}
public Map<String, Map<String, Long>> purchaseCooldowns() {
}
/**
* Returns the per-entry purchase cooldown table.
*
* @return mutable cooldown map
*/
public Map<String, Map<String, Long>> purchaseCooldowns() {
return purchaseCooldowns;
}
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
public Map<String, Long> bountyPlacementCooldowns() {
}
/**
* Returns the internal permission fallback table.
*
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
public Map<String, Integer> grantedRankPriorities() {
return grantedRankPriorities;
}
/**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns;
}
public Map<String, String> playerNames() {
}
/**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames;
}
/**
* 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;
}
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility;
}
}
public Map<String, Map<String, Long>> contractCooldowns() {
return contractCooldowns;
}
public Set<String> playerPlacedMiningTargets() {
return playerPlacedMiningTargets;
}
}
@@ -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);
}
}
}
}
@@ -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);
}
}
}
@@ -18,13 +18,25 @@ public final class BountyService {
private final SoulStealDataStore dataStore;
private final SoulService soulService;
public BountyService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.configSupplier = configSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
}
public PlaceBountyResult placeBounty(
public BountyService(Supplier<SoulStealConfig> 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<StoredBounty> claimed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
long reward = 0L;
@@ -108,9 +127,15 @@ public final class BountyService {
}
return new ClaimBountyResult(reward, claimed);
}
public List<StoredBounty> clearForTarget(UUID targetUuid) {
}
/**
* Clears all active bounties on a target without paying them out.
*
* @param targetUuid target player UUID
* @return the removed bounty records
*/
public List<StoredBounty> clearForTarget(UUID targetUuid) {
List<StoredBounty> removed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
String targetKey = key(targetUuid);
@@ -130,9 +155,15 @@ public final class BountyService {
}
return removed;
}
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
}
/**
* Processes expired bounties and pays the configured survivor reward where applicable.
*
* @param nowEpochMillis current time used to determine expiration
* @return payout records for every expired bounty handled in this pass
*/
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
List<ExpiredBountyPayout> payouts = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -155,18 +186,35 @@ public final class BountyService {
saveQuietly();
}
return payouts;
}
public List<StoredBounty> activeBounties() {
}
/**
* Returns a snapshot of all active bounties.
*
* @return immutable copy of the current active bounty list
*/
public List<StoredBounty> activeBounties() {
return List.copyOf(dataStore.data().activeBounties());
}
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) {
}
/**
* Returns all active bounties for a specific target player.
*
* @param targetUuid target player UUID
* @return bounties currently assigned to that player
*/
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) {
String targetKey = key(targetUuid);
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) {
}
}
}
@@ -0,0 +1,169 @@
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.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.registry.Registries;
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 (!isContractAvailable(player.getUuid(), contract.get())) {
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 boolean isContractAvailable(UUID playerUuid, ContractDefinition contract) {
String playerKey = key(playerUuid);
if (!contract.repeatable() && hasCompletedContract(playerUuid, contract.id())) {
return false;
}
// Repeatable contracts re-enter the browser only after their per-player cooldown expires.
if (contract.repeatable() && contract.cooldownSeconds() > 0L) {
long until = dataStore.data().contractCooldowns()
.getOrDefault(playerKey, Map.of())
.getOrDefault(contract.id(), 0L);
return System.currentTimeMillis() >= until;
}
return true;
}
public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId);
}
public void recordHunting(ServerPlayerEntity player, String entityId) {
record(player, ContractType.HUNTING, entityId);
}
public boolean matchesMiningTarget(net.minecraft.block.Block block) {
if (!catalogSupplier.get().enabled()) {
return false;
}
String blockId = Registries.BLOCK.getId(block).toString();
// Used when a player places a block that is also a mining target.
// We record that position so breaking the placed block does not count as real mining.
return catalogSupplier.get().contractsOfType(ContractType.MINING).stream()
.map(ContractDefinition::targetIds)
.filter(targetIds -> targetIds != null)
.flatMap(List::stream)
.anyMatch(target -> target.equalsIgnoreCase(blockId));
}
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) {
return;
}
boolean matches = contract.targetIds() != null && contract.targetIds().stream().anyMatch(t -> t.equalsIgnoreCase(targetId));
if (!matches) {
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()) {
// One-time contracts are permanently removed after completion.
dataStore.data().selectedContracts().remove(playerKey);
dataStore.data().completedContracts()
.computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>())
.add(contract.id());
} else {
if (contract.cooldownSeconds() > 0L) {
// Repeatable contracts with cooldown reappear after the timer expires.
dataStore.data().selectedContracts().remove(playerKey);
dataStore.data().contractCooldowns()
.computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>())
.put(contract.id(), System.currentTimeMillis() + (contract.cooldownSeconds() * 1000L));
} else {
// Repeatable contracts with no cooldown immediately restart.
progressMap.put(contract.id(), 0L);
}
}
} 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();
}
}
@@ -1,9 +1,11 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.util.DurationFormatter;
import 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 com.g2806.soulsteal.util.HudTexts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
@@ -15,55 +17,76 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.entity.boss.BossBar;
import net.minecraft.entity.boss.ServerBossBar;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.entity.boss.BossBar;
import net.minecraft.entity.boss.ServerBossBar;
import net.minecraft.network.packet.s2c.play.ScoreboardDisplayS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardObjectiveUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardScoreResetS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardScoreUpdateS2CPacket;
import net.minecraft.scoreboard.Scoreboard;
import net.minecraft.scoreboard.ScoreboardCriterion;
import net.minecraft.scoreboard.ScoreboardDisplaySlot;
import net.minecraft.scoreboard.ScoreboardObjective;
import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.scoreboard.ScoreboardCriterion;
import net.minecraft.scoreboard.ScoreboardDisplaySlot;
import net.minecraft.scoreboard.ScoreboardObjective;
import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.MutableText;
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 {
private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
private final BountyService bountyService;
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<>();
public HudService(
Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore,
SoulService soulService,
BountyService bountyService
) {
this.configSupplier = configSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
this.bountyService = bountyService;
}
public void handlePlayerJoin(ServerPlayerEntity player) {
public HudService(
Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore,
SoulService soulService,
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());
}
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<UUID> onlinePlayers = new HashSet<>();
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
onlinePlayers.add(player.getUuid());
@@ -79,14 +102,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 +130,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<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
playerKeys.addAll(dataStore.data().souls().keySet());
@@ -186,29 +234,48 @@ public final class HudService {
BossBar.Color.RED,
BossBar.Style.PROGRESS
));
bossBar.setName(Text.literal(configSupplier.get().hud().bountyBossbar().title() + ": " + totalValue + " souls | " + DurationFormatter.formatSeconds(remainingSeconds)));
bossBar.setPercent(percent);
MutableText bossbarText = HudTexts.title(configSupplier.get().hud().bountyBossbar().title())
.append(Text.literal(" "))
.append(HudTexts.value(String.valueOf(totalValue), Formatting.GOLD))
.append(Text.literal(" "))
.append(HudTexts.value("souls", Formatting.GRAY))
.append(Text.literal(" "))
.append(HudTexts.value("", Formatting.DARK_GRAY))
.append(Text.literal(" "))
.append(HudTexts.value(DurationFormatter.formatSeconds(remainingSeconds), Formatting.RED));
bossBar.setName(bossbarText);
bossBar.setPercent(percent);
bossBar.setVisible(true);
if (!bossBar.getPlayers().contains(player)) {
bossBar.addPlayer(player);
}
}
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
long remainingSeconds = activeBounties.stream()
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
return List.of(
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())),
Text.literal("Bounties: " + activeBounties.size()),
Text.literal("Wanted Value: " + totalValue),
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None"))
);
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
List<Text> lines = new ArrayList<>();
lines.add(HudTexts.labeledValue("Souls", String.valueOf(soulService.balanceOf(player.getUuid())), Formatting.GOLD));
contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
long progress = contractService.progress(player.getUuid(), contract.id());
lines.add(HudTexts.labeledValue("Contract", contract.name(), Formatting.WHITE));
lines.add(HudTexts.labeledValue("Progress", progress + "/" + contract.amountRequired(), 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(HudTexts.labeledValue("Bounties", String.valueOf(activeBounties.size()), Formatting.RED));
lines.add(HudTexts.labeledValue("Wanted", String.valueOf(totalValue), Formatting.GOLD));
lines.add(HudTexts.labeledValue("Time Left", DurationFormatter.formatSeconds(remainingSeconds), Formatting.DARK_RED));
}
return lines;
}
private void clearSidebar(ServerPlayerEntity player) {
@@ -235,14 +302,14 @@ public final class HudService {
private ScoreboardObjective createObjective(String objectiveName) {
Scoreboard scoreboard = new Scoreboard();
return scoreboard.addObjective(
objectiveName,
ScoreboardCriterion.DUMMY,
Text.literal(configSupplier.get().hud().scoreboard().title()),
ScoreboardCriterion.RenderType.INTEGER,
false,
BlankNumberFormat.INSTANCE
);
return scoreboard.addObjective(
objectiveName,
ScoreboardCriterion.DUMMY,
HudTexts.title(configSupplier.get().hud().scoreboard().title()),
ScoreboardCriterion.RenderType.INTEGER,
false,
BlankNumberFormat.INSTANCE
);
}
private void rememberPlayer(ServerPlayerEntity player) {
@@ -283,4 +350,4 @@ public final class HudService {
public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) {
}
}
}
@@ -1,17 +1,19 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
/**
* Bridges Soul Steal's permission checks to the Fabric Permissions API and optional LuckPerms.
@@ -19,14 +21,32 @@ import net.minecraft.server.network.ServerPlayerEntity;
* <p>Permission rewards first try LuckPerms for external integrations, then optionally fall back to
* a persisted internal store so Soul Steal's own nodes continue to work without extra mods.</p>
*/
public final class PermissionService {
private final SoulStealDataStore dataStore;
public PermissionService(SoulStealDataStore dataStore) {
this.dataStore = dataStore;
}
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
public final class PermissionService {
private final SoulStealDataStore dataStore;
private final Supplier<PermissionConfig> permissionConfigSupplier;
public PermissionService(SoulStealDataStore dataStore, Supplier<PermissionConfig> permissionConfigSupplier) {
this.dataStore = dataStore;
this.permissionConfigSupplier = permissionConfigSupplier;
}
public SoulStealDataStore dataStore() {
return dataStore;
}
public boolean isLuckPermsEnabled() {
return permissionConfigSupplier.get().luckpermsEnabled();
}
/**
* 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 +57,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,19 +95,36 @@ 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) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
}
/**
* 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;
if (storeFallback) {
@@ -91,9 +144,23 @@ public final class PermissionService {
grantedViaLuckPerms ? "Permission granted successfully." : "Permission stored in Soul Steal fallback permissions.");
}
return new GrantResult(false, false, false,
"No supported permissions backend was available for that reward.");
}
return new GrantResult(false, false, false,
"No supported permissions backend was available for that reward.");
}
public RankGrantResult grantLuckPermsGroup(UUID playerUuid, String group, boolean storeFallback) {
if (!isLuckPermsEnabled()) {
return new RankGrantResult(false, false, "LuckPerms is disabled in config.");
}
boolean granted = tryGrantLuckPermsGroup(playerUuid, group);
if (!granted) {
return new RankGrantResult(false, false, "No supported permissions backend was available for that rank.");
}
if (storeFallback) {
saveQuietly();
}
return new RankGrantResult(true, storeFallback, "Rank granted successfully.");
}
private boolean hasStoredPermission(UUID playerUuid, String permission) {
return dataStore.data().grantedPermissions()
@@ -101,8 +168,8 @@ public final class PermissionService {
.getOrDefault(permission, false);
}
private boolean tryGrantWithLuckPerms(UUID playerUuid, String permission, boolean value) {
try {
private boolean tryGrantWithLuckPerms(UUID playerUuid, String permission, boolean value) {
try {
Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider");
Object api = providerClass.getMethod("get").invoke(null);
Object userManager = api.getClass().getMethod("getUserManager").invoke(api);
@@ -127,9 +194,46 @@ public final class PermissionService {
return false;
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) {
SoulStealMod.LOGGER.warn("Failed to grant LuckPerms permission {} to {}", permission, playerUuid, exception);
return false;
}
}
return false;
}
}
private boolean tryGrantLuckPermsGroup(UUID playerUuid, String group) {
try {
Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider");
Object api = providerClass.getMethod("get").invoke(null);
Object userManager = api.getClass().getMethod("getUserManager").invoke(api);
Object groupManager = api.getClass().getMethod("getGroupManager").invoke(api);
Object lpGroup = groupManager.getClass().getMethod("getGroup", String.class).invoke(groupManager, group);
if (lpGroup == null) {
return false;
}
String groupName = String.valueOf(lpGroup.getClass().getMethod("getName").invoke(lpGroup));
Class<?> userClass = Class.forName("net.luckperms.api.model.user.User");
Consumer<Object> consumer = user -> {
try {
Object data = user.getClass().getMethod("data").invoke(user);
Class<?> nodeClass = Class.forName("net.luckperms.api.node.Node");
Class<?> inheritanceNodeClass = Class.forName("net.luckperms.api.node.types.InheritanceNode");
Object builder = inheritanceNodeClass.getMethod("builder", String.class).invoke(null, groupName);
Object builtNode = builder.getClass().getMethod("build").invoke(builder);
data.getClass().getMethod("add", nodeClass).invoke(data, builtNode);
userManager.getClass().getMethod("saveUser", userClass).invoke(userManager, user);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException exception) {
throw new RuntimeException(exception);
}
};
userManager.getClass().getMethod("modifyUser", UUID.class, Consumer.class).invoke(userManager, playerUuid, consumer);
return true;
} catch (ClassNotFoundException exception) {
return false;
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) {
SoulStealMod.LOGGER.warn("Failed to grant LuckPerms group {} to {}", group, playerUuid, exception);
return false;
}
}
private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) {
try {
@@ -192,6 +296,9 @@ public final class PermissionService {
return type;
}
public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) {
}
}
public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) {
}
public record RankGrantResult(boolean success, boolean storedInternally, String message) {
}
}
@@ -1,14 +1,18 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.shop.CommandRewardDefinition;
import com.g2806.soulsteal.shop.EffectRewardDefinition;
import com.g2806.soulsteal.shop.ItemRewardDefinition;
import com.g2806.soulsteal.shop.PermissionRewardDefinition;
import com.g2806.soulsteal.shop.RewardDefinition;
import com.g2806.soulsteal.shop.StackMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.g2806.soulsteal.shop.CommandRewardDefinition;
import com.g2806.soulsteal.shop.EffectRewardDefinition;
import com.g2806.soulsteal.shop.ItemRewardDefinition;
import com.g2806.soulsteal.shop.PermissionRewardDefinition;
import com.g2806.soulsteal.shop.RewardDefinition;
import com.g2806.soulsteal.shop.StackMode;
import com.g2806.soulsteal.shop.RankRewardDefinition;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent;
import net.minecraft.entity.effect.StatusEffect;
@@ -29,8 +33,8 @@ import net.minecraft.world.World;
/** Executes validated shop rewards for the player who bought an entry. */
public final class RewardService {
private final PermissionService permissionService;
private final SoulService soulService;
private final PermissionService permissionService;
private final SoulService soulService;
public RewardService(PermissionService permissionService, SoulService soulService) {
this.permissionService = permissionService;
@@ -50,15 +54,20 @@ public final class RewardService {
return new ValidationResult(false, "Unknown status effect id: " + effectReward.effectId());
}
}
case PermissionRewardDefinition permissionReward -> {
if (permissionReward.node().isBlank()) {
return new ValidationResult(false, "Permission rewards require a non-empty node.");
}
}
case CommandRewardDefinition commandReward -> {
if (commandReward.command().isBlank()) {
return new ValidationResult(false, "Command rewards require a non-empty command string.");
}
case PermissionRewardDefinition permissionReward -> {
if (permissionReward.node().isBlank()) {
return new ValidationResult(false, "Permission rewards require a non-empty node.");
}
}
case RankRewardDefinition rankReward -> {
if (rankReward.command().isBlank()) {
return new ValidationResult(false, "Rank rewards require a non-empty command.");
}
}
case CommandRewardDefinition commandReward -> {
if (commandReward.command().isBlank()) {
return new ValidationResult(false, "Command rewards require a non-empty command string.");
}
}
}
}
@@ -87,18 +96,25 @@ public final class RewardService {
applyEffectReward(player, effectEntry, effectReward);
granted.add(rewardDisplayName(effectReward));
}
case PermissionRewardDefinition permissionReward -> {
PermissionService.GrantResult result = permissionService.grantPersistentPermission(
player.getUuid(), permissionReward.node(), permissionReward.value(), permissionReward.storeFallback());
if (!result.success()) {
return new GrantResult(false, result.message(), granted);
}
granted.add(rewardDisplayName(permissionReward));
}
case CommandRewardDefinition commandReward -> {
executeCommandReward(player, commandReward);
granted.add(rewardDisplayName(commandReward));
}
case PermissionRewardDefinition permissionReward -> {
PermissionService.GrantResult result = permissionService.grantPersistentPermission(
player.getUuid(), permissionReward.node(), permissionReward.value(), permissionReward.storeFallback());
if (!result.success()) {
return new GrantResult(false, result.message(), granted);
}
granted.add(rewardDisplayName(permissionReward));
}
case RankRewardDefinition rankReward -> {
GrantResult rankResult = grantRankReward(player, rankReward);
if (!rankResult.success()) {
return new GrantResult(false, rankResult.message(), granted);
}
granted.add(rewardDisplayName(rankReward));
}
case CommandRewardDefinition commandReward -> {
executeCommandReward(player, commandReward);
granted.add(rewardDisplayName(commandReward));
}
}
}
@@ -111,11 +127,12 @@ public final class RewardService {
switch (reward) {
case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY));
case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY));
case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY));
case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY));
}
}
return lines;
case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY));
case RankRewardDefinition rankReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(rankReward)).formatted(Formatting.GRAY));
case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY));
}
}
return lines;
}
public boolean supportsCustomAmount(List<RewardDefinition> rewards) {
@@ -215,12 +232,19 @@ public final class RewardService {
return reward.effectId();
}
private String rewardDisplayName(PermissionRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName();
}
return "permission " + reward.node();
}
private String rewardDisplayName(PermissionRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName();
}
return "permission " + reward.node();
}
private String rewardDisplayName(RankRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName();
}
return "rank command";
}
private String rewardDisplayName(CommandRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
@@ -245,17 +269,37 @@ public final class RewardService {
return Registries.ITEM.get(identifier);
}
private RegistryEntry<StatusEffect> resolveStatusEffect(String effectId) {
Identifier identifier = Identifier.tryParse(effectId);
if (identifier == null) {
return null;
}
return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null);
}
private RegistryEntry<StatusEffect> resolveStatusEffect(String effectId) {
Identifier identifier = Identifier.tryParse(effectId);
if (identifier == null) {
return null;
}
return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null);
}
private GrantResult grantRankReward(ServerPlayerEntity player, RankRewardDefinition reward) {
String playerKey = player.getUuidAsString();
int currentPriority = permissionService.dataStore().data().grantedRankPriorities().getOrDefault(playerKey, Integer.MIN_VALUE);
if (currentPriority >= reward.priority()) {
return new GrantResult(false, "You already own an equal or higher rank.", List.of());
}
permissionService.dataStore().data().grantedRankPriorities().put(playerKey, reward.priority());
try {
permissionService.dataStore().save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist rank reward data.", exception);
}
executeCommandReward(player, new CommandRewardDefinition(
reward.command(),
reward.runAsConsole(),
reward.displayName()
));
return new GrantResult(true, "Rank command executed successfully.", List.of(reward.command()));
}
public record ValidationResult(boolean success, String message) {
}
public record GrantResult(boolean success, String message, List<String> grantedRewards) {
}
}
}
@@ -49,36 +49,57 @@ public final class ShopService {
private final RewardService rewardService;
private final SoulStealDataStore dataStore;
public ShopService(
Supplier<ConfigBundle> bundleSupplier,
SoulService soulService,
RewardService rewardService,
SoulStealDataStore dataStore
public ShopService(
Supplier<ConfigBundle> bundleSupplier,
SoulService soulService,
RewardService rewardService,
SoulStealDataStore dataStore
) {
this.bundleSupplier = bundleSupplier;
this.soulService = soulService;
this.rewardService = rewardService;
this.dataStore = dataStore;
}
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) {
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) {
}
}
}
@@ -13,21 +13,41 @@ public final class SoulService {
private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore;
public SoulService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore) {
this.configSupplier = configSupplier;
this.dataStore = dataStore;
}
public long balanceOf(UUID playerUuid) {
public SoulService(Supplier<SoulStealConfig> 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) {
}
}
}
@@ -0,0 +1,9 @@
package com.g2806.soulsteal.shop;
/** Rank reward backed by a command and a track priority. */
public record RankRewardDefinition(String command, int priority, boolean runAsConsole, String displayName) implements RewardDefinition {
@Override
public RewardType type() {
return RewardType.RANK;
}
}
@@ -1,6 +1,6 @@
package com.g2806.soulsteal.shop;
/** Marker interface for all shop reward definitions. */
public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition {
RewardType type();
}
public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition, RankRewardDefinition {
RewardType type();
}
@@ -1,9 +1,10 @@
package com.g2806.soulsteal.shop;
/** Supported reward types that can be granted by the soul shop. */
public enum RewardType {
ITEM,
PERMISSION,
EFFECT,
COMMAND
}
public enum RewardType {
ITEM,
PERMISSION,
EFFECT,
COMMAND,
RANK
}
@@ -1,17 +1,18 @@
package com.g2806.soulsteal.shop;
import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig;
import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig;
import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/** Parsed representation of the editable shop catalog. */
public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) {
public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi) {
public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) {
public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi, PermissionConfig permissionConfig) {
Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories");
List<ShopCategoryDefinition> categories = new ArrayList<>();
@@ -30,7 +31,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
}
Map<String, Object> itemMap = toStringMap(rawItemMap);
List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards"));
List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards"), permissionConfig.luckpermsEnabled());
if (rewards.isEmpty()) {
continue;
}
@@ -58,7 +59,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
));
}
return new ShopCatalog(shopUi.title(), shopUi.rows(), shopUi.fillerItemId(), categories);
return new ShopCatalog(shopUi.title(), shopUi.rows(), shopUi.fillerItemId(), categories);
}
public Optional<ShopCategoryDefinition> category(String key) {
@@ -111,11 +112,11 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
stack_mode: ADD_DURATION
name: "Speed Boost"
unlocks:
name: "Unlocks"
icon: "minecraft:nether_star"
items:
nickname_access:
unlocks:
name: "Unlocks"
icon: "minecraft:nether_star"
items:
nickname_access:
slot: 13
icon: "minecraft:name_tag"
name: "Nickname Access"
@@ -124,17 +125,33 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
- "Requires LuckPerms for external permissions."
cost: 1000
repeatable: false
rewards:
- type: permission
node: "example.nick"
value: true
store_fallback: true
name: "Nickname Permission"
utility_commands:
name: "Command Hooks"
icon: "minecraft:command_block"
items:
rewards:
- type: permission
node: "example.nick"
value: true
store_fallback: true
name: "Nickname Permission"
vip_rank:
slot: 14
icon: "minecraft:diamond_chestplate"
name: "VIP Rank"
description:
- "Runs a command when bought."
- "Ranks are tracked by priority so lower ranks cannot overwrite higher ones."
cost: 5000
repeatable: false
rewards:
- type: rank
priority: 20
run_as_console: true
command: "lp user %player% parent add vip"
name: "VIP Rank"
utility_commands:
name: "Command Hooks"
icon: "minecraft:command_block"
items:
starter_crate:
slot: 15
icon: "minecraft:chest"
@@ -152,7 +169,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
""";
}
private static List<RewardDefinition> parseRewards(List<Object> rawRewards) {
private static List<RewardDefinition> parseRewards(List<Object> rawRewards, boolean luckPermsEnabled) {
List<RewardDefinition> rewards = new ArrayList<>();
for (Object rewardValue : rawRewards) {
@@ -176,16 +193,27 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
Math.max(1, YamlConfigHelper.intValue(rewardMap, "amount", 1)),
rewardName
));
case PERMISSION -> rewards.add(new PermissionRewardDefinition(
YamlConfigHelper.string(rewardMap, "node", "soulsteal.example"),
YamlConfigHelper.bool(rewardMap, "value", true),
YamlConfigHelper.bool(rewardMap, "store_fallback", true),
rewardName
));
case EFFECT -> {
StackMode stackMode;
try {
stackMode = StackMode.valueOf(YamlConfigHelper.string(rewardMap, "stack_mode", StackMode.REPLACE.name()).toUpperCase(Locale.ROOT));
case PERMISSION -> rewards.add(new PermissionRewardDefinition(
YamlConfigHelper.string(rewardMap, "node", "soulsteal.example"),
YamlConfigHelper.bool(rewardMap, "value", true),
YamlConfigHelper.bool(rewardMap, "store_fallback", true),
rewardName
));
case RANK -> {
if (!luckPermsEnabled) {
continue;
}
rewards.add(new RankRewardDefinition(
YamlConfigHelper.string(rewardMap, "command", "say %player% purchased a rank."),
Math.max(0, YamlConfigHelper.intValue(rewardMap, "priority", 0)),
YamlConfigHelper.bool(rewardMap, "run_as_console", true),
rewardName
));
}
case EFFECT -> {
StackMode stackMode;
try {
stackMode = StackMode.valueOf(YamlConfigHelper.string(rewardMap, "stack_mode", StackMode.REPLACE.name()).toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ignored) {
stackMode = StackMode.REPLACE;
}
@@ -226,4 +254,4 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
}
return converted;
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,31 @@
package com.g2806.soulsteal.util;
import net.minecraft.text.MutableText;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/** Text helpers for sidebar, bossbar, and other compact HUD surfaces. */
public final class HudTexts {
private HudTexts() {
}
public static MutableText title(String text) {
return Text.literal(text).formatted(Formatting.DARK_AQUA, Formatting.BOLD);
}
public static MutableText label(String text) {
return Text.literal(text).formatted(Formatting.AQUA, Formatting.BOLD);
}
public static MutableText value(String text, Formatting formatting) {
return Text.literal(text).formatted(formatting);
}
public static MutableText labeledValue(String label, String value, Formatting valueFormatting) {
return label(label).append(Text.literal(" ")).append(value(value, valueFormatting));
}
public static MutableText separator() {
return Text.literal(" ").formatted(Formatting.GRAY);
}
}
@@ -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));
}
}
}
@@ -0,0 +1,21 @@
package com.g2806.soulsteal;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SoulStealModTest {
@Test
void placedBlockPositionUsesClickedFaceOffset() {
BlockPos clicked = new BlockPos(10, 64, -3);
assertEquals(new BlockPos(10, 65, -3), SoulStealMod.placedBlockPos(clicked, Direction.UP));
assertEquals(new BlockPos(10, 63, -3), SoulStealMod.placedBlockPos(clicked, Direction.DOWN));
assertEquals(new BlockPos(11, 64, -3), SoulStealMod.placedBlockPos(clicked, Direction.EAST));
assertEquals(new BlockPos(9, 64, -3), SoulStealMod.placedBlockPos(clicked, Direction.WEST));
assertEquals(new BlockPos(10, 64, -4), SoulStealMod.placedBlockPos(clicked, Direction.NORTH));
assertEquals(new BlockPos(10, 64, -2), SoulStealMod.placedBlockPos(clicked, Direction.SOUTH));
}
}
@@ -0,0 +1,38 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ContractCatalogTest {
@Test
void parsesCooldownAndDisplayMatches() {
Map<String, Object> root = Map.of(
"contracts", Map.of(
"mining", List.of(Map.of(
"id", "iron_miner",
"type", "mining",
"name", "Iron Miner",
"targets", List.of("minecraft:iron_ore", "minecraft:deepslate_iron_ore"),
"matches", List.of("Iron Ore", "Deepslate Iron Ore"),
"target_name", "Iron Ore",
"amount", 20,
"reward", 200,
"repeatable", true,
"cooldown", 10
))
)
);
SoulStealConfig.ContractConfig config = new SoulStealConfig.ContractConfig(true, true, new SoulStealConfig.ContractHudConfig(true, "Active Contract"), 0L);
ContractCatalog catalog = ContractCatalog.fromMap(root, config);
ContractDefinition contract = catalog.contract("iron_miner").orElseThrow();
assertEquals(10L, contract.cooldownSeconds());
assertEquals(List.of("Iron Ore", "Deepslate Iron Ore"), contract.displayMatches());
assertEquals(2, contract.targetIds().size());
}
}
@@ -0,0 +1,98 @@
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.util.LinkedHashMap;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ContractServiceTest {
@Test
void repeatableContractIsBlockedUntilCooldownExpires() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("repeatable", true, 30L, List.of("minecraft:iron_ore"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
Map<String, Long> cooldowns = new LinkedHashMap<>();
cooldowns.put("repeatable", System.currentTimeMillis() + 60_000L);
dataStore.data().contractCooldowns().put(player.toString(), cooldowns);
assertFalse(service.isContractAvailable(player, contract));
cooldowns = new LinkedHashMap<>();
cooldowns.put("repeatable", System.currentTimeMillis() - 1L);
dataStore.data().contractCooldowns().put(player.toString(), cooldowns);
assertTrue(service.isContractAvailable(player, contract));
}
@Test
void completedOneTimeContractIsUnavailable() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("one_time", false, 0L, List.of("minecraft:zombie"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
java.util.Set<String> completed = new java.util.HashSet<>();
completed.add("one_time");
dataStore.data().completedContracts().put(player.toString(), completed);
assertFalse(service.isContractAvailable(player, contract));
}
@Test
void selectedContractReadsFromPersistentData() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("iron_miner", true, 0L, List.of("minecraft:iron_ore"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
dataStore.data().selectedContracts().put(player.toString(), "iron_miner");
assertTrue(service.selectedContract(player).isPresent());
assertEquals("iron_miner", service.selectedContract(player).orElseThrow().id());
}
private static ContractDefinition contract(String id, boolean repeatable, long cooldownSeconds, List<String> targets) {
return new ContractDefinition(id, id, "minecraft:iron_pickaxe", ContractType.MINING, targets, List.of(), "Iron Ore", "", 20L, 250L, repeatable, cooldownSeconds);
}
private static ContractCatalog catalog(ContractDefinition contract) {
return new ContractCatalog(true, true, true, "Active Contract", List.of(contract));
}
private static SoulStealDataStore newDataStore() throws Exception {
Path dir = Files.createTempDirectory("soulsteal-contract-test");
SoulStealDataStore store = new SoulStealDataStore(dir);
store.load();
return store;
}
private static SoulService newSoulService(SoulStealDataStore dataStore) {
return new SoulService(() -> new com.g2806.soulsteal.config.SoulStealConfig(
new com.g2806.soulsteal.config.SoulStealConfig.EconomyConfig(0L, 1_000_000L, 25L,
new com.g2806.soulsteal.config.SoulStealConfig.DeathPenaltyConfig(15L, 0.10D, 5L, 100L),
new com.g2806.soulsteal.config.SoulStealConfig.TransferConfig(true, 1L)),
new com.g2806.soulsteal.config.SoulStealConfig.BountyConfig(true, 25L, 10_000L, 7_200L, 600L, 86_400L, 0.50D, 60L, 5, 3),
new com.g2806.soulsteal.config.SoulStealConfig.TrackerConfig(true, 900L, 20, false),
new com.g2806.soulsteal.config.SoulStealConfig.ContractConfig(true, true, new com.g2806.soulsteal.config.SoulStealConfig.ContractHudConfig(true, "Active Contract"), 0L),
new com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig("Soul Shop", 3, "minecraft:black_stained_glass_pane", 0L, true, 64),
new com.g2806.soulsteal.config.SoulStealConfig.HudConfig(
new com.g2806.soulsteal.config.SoulStealConfig.ScoreboardConfig(true, false, "Soul HUD"),
new com.g2806.soulsteal.config.SoulStealConfig.BountyBossbarConfig(true, "Bounty on You"),
new com.g2806.soulsteal.config.SoulStealConfig.LeaderboardConfig(10)
),
new com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", true, "balance", "set", "add", "take", "scoreboard", "leaderboard")
), dataStore);
}
}
@@ -0,0 +1,78 @@
package com.g2806.soulsteal.shop;
import com.g2806.soulsteal.config.SoulStealConfig;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ShopCatalogTest {
@Test
void filtersRankRewardsWhenLuckPermsIsDisabled() {
Map<String, Object> root = Map.of(
"categories", Map.of(
"ranks", Map.of(
"name", "Ranks",
"items", Map.of(
"vip", Map.of(
"name", "VIP",
"cost", 1000,
"repeatable", false,
"rewards", List.of(Map.of(
"type", "rank",
"priority", 20,
"run_as_console", true,
"command", "lp user %player% parent add vip",
"name", "VIP Rank"
))
)
)
)
)
);
SoulStealConfig.ShopUiConfig shopUi = new SoulStealConfig.ShopUiConfig("Shop", 3, "minecraft:glass", 0L, true, 64);
SoulStealConfig.PermissionConfig permissions = new SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", false, "balance", "set", "add", "take", "scoreboard", "leaderboard");
ShopCatalog catalog = ShopCatalog.fromMap(root, shopUi, permissions);
assertTrue(catalog.category("ranks").orElseThrow().entries().isEmpty());
}
@Test
void parsesRankRewardsWhenLuckPermsIsEnabled() {
Map<String, Object> root = Map.of(
"categories", Map.of(
"ranks", Map.of(
"name", "Ranks",
"items", Map.of(
"vip", Map.of(
"name", "VIP",
"cost", 1000,
"repeatable", false,
"rewards", List.of(Map.of(
"type", "rank",
"priority", 20,
"run_as_console", true,
"command", "lp user %player% parent add vip",
"name", "VIP Rank"
))
)
)
)
)
);
SoulStealConfig.ShopUiConfig shopUi = new SoulStealConfig.ShopUiConfig("Shop", 3, "minecraft:glass", 0L, true, 64);
SoulStealConfig.PermissionConfig permissions = new SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", true, "balance", "set", "add", "take", "scoreboard", "leaderboard");
ShopCatalog catalog = ShopCatalog.fromMap(root, shopUi, permissions);
ShopEntryDefinition entry = catalog.category("ranks").orElseThrow().entries().get(0);
assertEquals(1, entry.rewards().size());
assertInstanceOf(RankRewardDefinition.class, entry.rewards().get(0));
RankRewardDefinition reward = (RankRewardDefinition) entry.rewards().get(0);
assertEquals(20, reward.priority());
assertEquals("lp user %player% parent add vip", reward.command());
assertTrue(reward.runAsConsole());
}
}
@@ -0,0 +1,15 @@
package com.g2806.soulsteal.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DurationFormatterTest {
@Test
void formatsSecondsIntoReadableUnits() {
assertEquals("10s", DurationFormatter.formatSeconds(10));
assertEquals("10m", DurationFormatter.formatSeconds(600));
assertEquals("2h", DurationFormatter.formatSeconds(7_200));
assertEquals("1d 3h", DurationFormatter.formatSeconds(97_200));
}
}