4 Commits

Author SHA1 Message Date
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
24 changed files with 2179 additions and 591 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}"
+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. |
+341
View File
@@ -0,0 +1,341 @@
# Soul Steal Project Documentation
## Overview
Soul Steal is a server-side Fabric mod that implements a soul economy, player bounties, tracker compasses, a vanilla-style shop GUI, optional HUD elements, and permission-backed admin controls.
The mod is built to run entirely on the server. Players do not need a client mod to use the commands or the shop.
## Runtime Architecture
The entry point is [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java), which performs four jobs:
1. Loads configuration from `config/soulsteal/config.yml`.
2. Loads persistent player data from `config/soulsteal/soulsteal-data.json`.
3. Instantiates the feature services.
4. Registers command, tick, join, death, and disconnect handlers.
The main services are:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java): balance reads and mutations.
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java): bounty placement, expiry, and kill claims.
- [`TrackerCompassService`](../src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java): temporary tracking compasses for player kills.
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java): server-side shop GUI and purchases.
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java): validates and grants rewards from shop entries.
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java): permission lookups and fallback storage.
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java): scoreboard sidebar, leaderboard, and bounty bossbar.
Shared utilities:
- [`SoulTexts`](../src/main/java/com/g2806/soulsteal/util/SoulTexts.java): formatted feedback, success, warning, and error messages.
- [`DurationFormatter`](../src/main/java/com/g2806/soulsteal/util/DurationFormatter.java): human-readable duration strings.
## Feature Summary
### Soul Economy
Players have a soul balance stored per UUID.
- Killing another player can grant souls.
- Dying can remove souls using configurable flat and percentage penalties.
- Transfers between players can be enabled or disabled in config.
- Admin commands can set, add, or remove balances directly.
Implemented by:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java)
- [`SoulCommandRegistrar`](../src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java)
### Bounties
Players can place timed bounties on other players using their souls as the payment source.
Behavior:
- Placement is bounded by min/max value, min/max duration, cooldowns, and active bounty limits.
- Killing the target claims all active bounties on that target.
- If a bounty expires, the target receives a configured survivor payout.
- Bounties are cleared if the target dies to non-player damage and no killer claims them.
Implemented by:
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java)
- [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java)
### Tracker Compasses
When enabled, killing a player grants a temporary tracker compass.
Behavior:
- The compass tracks the killed player using lodestone-tracker data.
- The item stores target UUID, target name, and expiration time in custom NBT.
- Expired compasses are removed during server ticks.
- Optional behavior can remove the compass if the target is offline.
Admins can also grant a tracker compass directly with `/souls tracker give <player> <target>`. This uses the same tracker data and duration as the kill-granted version.
### Contracts
Contracts let players earn souls without PvP by completing mining and hunting goals.
Behavior:
- Players pick one active contract at a time.
- Mining contracts track block breaks against a configured block id.
- Hunting contracts track kills against a configured entity id.
- The selected contract and its progress appear in the HUD sidebar.
- Completing a contract pays souls automatically.
- One-time contracts disappear from the browser after completion and cannot be selected again.
- The contract browser uses `catalog.yml`, where the YAML key is the internal contract id and `name` is the player-facing label.
- Contract entries are organized under `contracts: mining:` and `contracts: hunting:` sections for readability, while still using per-entry ids.
- The contract browser uses a bottom control row like the shop, including page navigation anchored to the bottom of the inventory.
- The contract browser includes a clear-selection action on the home page and inside contract categories so players can remove their active contract from the GUI.
Implemented by:
- [`ContractService`](../src/main/java/com/g2806/soulsteal/service/ContractService.java)
- [`ContractGuiService`](../src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java)
- [`ContractCatalog`](../src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java)
- [`ContractDefinition`](../src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java)
### Shop
The shop is a chest-based GUI rendered entirely with vanilla screen handler APIs.
Behavior:
- The home view lists categories with pagination.
- Category views list entries with pagination.
- Item entries can be direct purchases or quantity-select purchases.
- Purchases can be repeatable or single-unlock.
- Cooldowns can be stored per entry per player.
- Rewards can include items, effects, commands, and permissions.
Implemented by:
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java)
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java)
- [`SoulShopScreenHandler`](../src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java)
- [`ShopCatalog`](../src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java)
### HUD
The HUD layer is optional and configurable per player.
Behavior:
- Scoreboard sidebar can be enabled globally and toggled per player.
- The sidebar title uses the same dark aqua styling as the mod's chat prefix.
- Contract and bounty rows only appear while the player has an active selected contract or active bounties.
- Balance, contract, and bounty rows use color to make the sidebar easier to scan.
- Leaderboard pages are built from stored player names and balance values.
- Wanted-player bossbars show bounty value and remaining time.
Implemented by:
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java)
### Permissions
The mod checks permissions in two layers:
1. External permission backends through the Fabric permissions API and optional LuckPerms integration.
2. Internal fallback storage persisted in `soulsteal-data.json`.
This allows reward-granted permissions and admin nodes to keep working even without an external permission mod.
Implemented by:
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java)
## Command Reference
The root command has two aliases:
- `/souls`
- `/soul`
### Player Commands
- `/souls`
- Shows your current balance.
- `/souls balance`
- Same as `/souls`.
- `/souls balance <player>`
- Shows another players balance.
- `/souls pay <player> <amount>`
- Transfers souls to another player.
- `/souls shop [category]`
- Opens the shop GUI.
- `/souls bounty place <player> <amount> [durationSeconds]`
- Places a bounty.
- `/souls bounty list [player]`
- Lists active bounties.
- `/souls scoreboard`
- Shows scoreboard visibility state.
- `/souls scoreboard toggle`
- Toggles the players scoreboard preference.
- `/souls scoreboard on`
- Forces scoreboard visibility on.
- `/souls scoreboard off`
- Forces scoreboard visibility off.
- `/souls top [page]`
- Shows leaderboard pages.
- `/souls tracker give <player> <target>`
- Gives a tracker compass to one player that points at another player.
- `/souls contracts`
- Opens the contract browser GUI.
- `/souls contracts selected`
- Shows the currently selected contract and progress.
- `/souls contracts clear`
- Clears your selected contract.
### Admin Commands
- `/souls reload`
- Reloads config and shop definitions.
- `/souls set <player> <amount>`
- Sets a balance directly.
- `/souls add <player> <amount>`
- Adds souls to a balance.
- `/souls take <player> <amount>`
- Removes souls from a balance.
Permission defaults and node names are configured in `config.yml`.
## Configuration Files
### `config/soulsteal/config.yml`
Primary configuration file. It controls:
- Economy values
- Death penalties
- Transfer rules
- Bounty limits and payouts
- Tracker compass behavior
- Shop UI defaults
- HUD toggles and titles
- Permission node names
The schema is defined in [`SoulStealConfig`](../src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java).
### `config/soulsteal/shop.yml`
Shop catalog definition. It controls:
- Shop title and layout
- Category list
- Entry pricing
- Entry cooldowns
- Reward definitions
- Optional custom quantity selector behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/catalog.yml`
Contract catalog definition. It controls:
- Grouped contract lists under `mining` and `hunting`
- Internal contract ids from each entry `id`
- Player-facing contract names
- Icon item ids
- Target block or mob ids
- Required amounts and rewards
- Repeatable behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/soulsteal-data.json`
Persistent runtime data. It stores:
- Player soul balances
- Active bounties
- Bounty placement cooldowns
- Shop unlocks
- Shop purchase cooldowns
- Permission fallback grants
- Player name history
- Scoreboard visibility preferences
- Selected contracts
- Contract progress
Loaded and saved by [`SoulStealDataStore`](../src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java).
## Data Flow
### Player Join
When a player joins:
1. Their name is recorded in persistent data.
2. Their HUD state is refreshed.
3. Any configured scoreboard or bossbar state is pushed to them.
### HUD Sidebar
When the sidebar is visible:
1. The title is rendered in dark aqua.
2. The soul balance row is always shown.
3. Contract rows are only shown if the player has an active selected contract.
4. Bounty rows are only shown if the player currently has active bounties.
5. The visible rows use color to distinguish balance, contract, and bounty information.
### Player Kill
When one player kills another player:
1. The killer receives kill reward souls if enabled.
2. Any matching bounties on the victim are claimed.
3. The killer receives a tracker compass if the tracker feature is enabled.
4. Any matching hunting contract progresses if the killer has one selected.
### Block Break
When a player breaks a block:
1. Any matching mining contract progresses if the player has one selected.
### Player Death
When a player dies:
1. The configured death penalty is applied.
2. If the death was not caused by another player, unclaimed bounties on the victim can be cleared.
### Server Tick
On server tick:
1. Tracker compasses are updated and expired compasses are removed.
2. Bounty expirations are processed once per second.
3. HUD elements are refreshed.
### Server Shutdown
On shutdown:
- Persistent data is saved to disk.
## Project Structure
- `src/main/java/com/g2806/soulsteal/`
- Bootstrap, commands, services, config, data, shop, and utilities.
- `src/main/java/com/g2806/soulsteal/contract/`
- Contract catalog, GUI, and screen handling classes.
- `src/main/resources/`
- Fabric metadata and resource files.
- `build.gradle`
- Fabric Loom build configuration.
## Development Notes
- The mod targets Java 21.
- It is structured as a server-only feature set, so most behavior is driven by server lifecycle events.
- Persistence is intentionally simple: one JSON data file and YAML-driven configuration.
- External integrations are reflected through reflection-based checks so the mod can run without hard dependencies on permission backends.
- Contract configuration is separated into `catalog.yml` so contract entries can be expanded without changing the main economy config.
+1 -1
View File
@@ -10,7 +10,7 @@ loom_version=1.16.1
fabric_api_version=0.141.3+1.21.11
# Mod Properties
mod_version=0.3.0
mod_version=0.4.0
maven_group=com.g2806.soulsteal
archives_base_name=soul-steal
@@ -1,196 +1,276 @@
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.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource;
import net.minecraft.registry.Registries;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
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);
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());
}
});
PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
if (player instanceof ServerPlayerEntity serverPlayer) {
contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
}
});
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
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);
}
}
/**
* 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());
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);
}
}
@@ -5,21 +5,23 @@ import java.util.Map;
/**
* Main configuration tree for the mod's economy, bounty, tracker, and permission settings.
*/
public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ShopUiConfig shop,
HudConfig hud,
PermissionConfig permissions
) {
public record SoulStealConfig(
EconomyConfig economy,
BountyConfig bounty,
TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop,
HudConfig hud,
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> shopSection = YamlConfigHelper.section(root, "shop");
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");
Map<String, Object> bossbarSection = YamlConfigHelper.section(hudSection, "bounty_bossbar");
@@ -62,12 +64,21 @@ public record SoulStealConfig(
bountyConfig = bountyConfig.withMaxDurationSeconds(bountyConfig.minDurationSeconds());
}
TrackerConfig trackerConfig = new TrackerConfig(
YamlConfigHelper.bool(trackerSection, "enabled", true),
Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)),
Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)),
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
);
TrackerConfig trackerConfig = new TrackerConfig(
YamlConfigHelper.bool(trackerSection, "enabled", true),
Math.max(30L, YamlConfigHelper.longValue(trackerSection, "duration_seconds", 900L)),
Math.max(1, YamlConfigHelper.intValue(trackerSection, "update_interval_ticks", 20)),
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
);
ContractConfig contractConfig = new ContractConfig(
YamlConfigHelper.bool(contractsSection, "enabled", true),
YamlConfigHelper.bool(contractsSection, "auto_claim", true),
new ContractHudConfig(
YamlConfigHelper.bool(contractsSection, "hud_enabled", true),
YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract")
)
);
ShopUiConfig shopUiConfig = new ShopUiConfig(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
@@ -106,8 +117,8 @@ public record SoulStealConfig(
YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard")
);
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, shopUiConfig, hudConfig, permissionConfig);
}
return new SoulStealConfig(economyConfig, bountyConfig, trackerConfig, contractConfig, shopUiConfig, hudConfig, permissionConfig);
}
public static String defaultYaml() {
return """
@@ -136,13 +147,19 @@ public record SoulStealConfig(
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
shop:
tracker:
enabled: true
duration_seconds: 900
update_interval_ticks: 20
expire_if_target_offline: false
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
shop:
title: "Soul Shop"
rows: 3
filler_item: "minecraft:black_stained_glass_pane"
@@ -216,8 +233,14 @@ public record SoulStealConfig(
}
}
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
}
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
}
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud) {
}
public record ContractHudConfig(boolean enabled, String title) {
}
public record ShopUiConfig(
String title,
@@ -254,4 +277,4 @@ public record SoulStealConfig(
String leaderboardNode
) {
}
}
}
@@ -0,0 +1,113 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig;
import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Parsed contract catalog loaded from `catalog.yml`.
*
* <p>Contracts are grouped by type in the GUI, but remain flat in the config so each entry can be
* addressed by its own id.</p>
*/
public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) {
public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) {
List<ContractDefinition> contracts = new ArrayList<>();
Object rawContracts = root.get("contracts");
if (rawContracts instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract);
}
} else if (rawContracts instanceof Map<?, ?> rawSections) {
for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) {
if (sectionEntry.getValue() instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract);
}
}
}
}
return new ContractCatalog(config.enabled(), config.autoClaim(), config.hud().enabled(), config.hud().title(), contracts);
}
public Optional<ContractDefinition> contract(String id) {
return contracts.stream().filter(contract -> contract.id().equalsIgnoreCase(id)).findFirst();
}
public List<ContractDefinition> contractsOfType(ContractType type) {
return contracts.stream().filter(contract -> contract.type() == type).toList();
}
public static String defaultYaml() {
return """
contracts:
mining:
- id: "iron_miner"
name: "Mining Contracts"
icon: "minecraft:iron_pickaxe"
type: "mining"
target: "minecraft:iron_ore"
target_name: "Iron Ore"
description: "Mine iron ore to earn souls."
amount: 64
reward: 250
repeatable: true
hunting:
- id: "zombie_hunter"
name: "Zombie Hunter"
icon: "minecraft:zombie_head"
type: "hunting"
target: "minecraft:zombie"
target_name: "Zombie"
description: "Hunt zombies to earn souls."
amount: 20
reward: 200
repeatable: true
""";
}
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
Map<String, Object> converted = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() != null) {
converted.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return converted;
}
private static void addContract(List<ContractDefinition> contracts, Object rawContract) {
if (!(rawContract instanceof Map<?, ?> rawMap)) {
return;
}
Map<String, Object> map = toStringMap(rawMap);
String id = YamlConfigHelper.string(map, "id", "").trim();
if (id.isBlank()) {
return;
}
String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase();
ContractType type;
try {
type = ContractType.valueOf(typeName);
} catch (IllegalArgumentException ignored) {
return;
}
contracts.add(new ContractDefinition(
id,
YamlConfigHelper.string(map, "name", id),
YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"),
type,
YamlConfigHelper.string(map, "target", ""),
YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)),
YamlConfigHelper.string(map, "description", ""),
Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)),
Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)),
YamlConfigHelper.bool(map, "repeatable", true)
));
}
}
@@ -0,0 +1,21 @@
package com.g2806.soulsteal.contract;
/**
* Immutable definition for one contract entry loaded from `catalog.yml`.
*
* <p>The YAML key is the internal id used for selection and persistence, while {@code name} is
* the player-facing label shown in the GUI and HUD.</p>
*/
public record ContractDefinition(
String id,
String name,
String iconItemId,
ContractType type,
String targetId,
String targetName,
String description,
long amountRequired,
long reward,
boolean repeatable
) {
}
@@ -0,0 +1,297 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/**
* Builds the server-side contract browser and dispatches click handling for contract selection.
*
* <p>The contract UI follows the same chest-based presentation style as the shop GUI so players
* can scan categories, read contract details, and select an active objective without client-side
* mods.</p>
*/
public final class ContractGuiService {
private static final int PAGE_ROWS = 6;
private static final int ITEM_SLOT_COUNT = 45;
private static final int SLOT_HOME = 45;
private static final int SLOT_PREVIOUS = 46;
private static final int SLOT_INFO = 49;
private static final int SLOT_CLEAR = 52;
private static final int SLOT_NEXT = 53;
private final Supplier<ConfigBundle> bundleSupplier;
private final ContractService contractService;
private final RewardService rewardService;
private final SoulService soulService;
public ContractGuiService(Supplier<ConfigBundle> bundleSupplier, ContractService contractService, RewardService rewardService, SoulService soulService) {
this.bundleSupplier = bundleSupplier;
this.contractService = contractService;
this.rewardService = rewardService;
this.soulService = soulService;
}
public void openContracts(ServerPlayerEntity player) {
openHome(player, 0);
}
public void openHome(ServerPlayerEntity player, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new HomeView(page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public void openCategory(ServerPlayerEntity player, String categoryKey, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new CategoryView(categoryKey, page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public SimpleInventory createInventory(ServerPlayerEntity player, View view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView);
};
}
public void handleClick(ServerPlayerEntity player, View view, int slotIndex) {
if (view instanceof HomeView homeView) {
handleHomeClick(player, homeView, slotIndex);
} else if (view instanceof CategoryView categoryView) {
handleCategoryClick(player, categoryView, slotIndex);
}
}
public ContractDefinition selected(ServerPlayerEntity player) {
return contractService.selectedContract(player.getUuid()).orElse(null);
}
public long progress(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return selected == null ? 0L : contractService.progress(player.getUuid(), selected.id());
}
public long progress(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.progress(player.getUuid(), contract.id());
}
public boolean select(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.selectContract(player, contract.id());
}
public void clearSelection(ServerPlayerEntity player) {
contractService.clearContract(player.getUuid());
}
private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> mining = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
List<ContractDefinition> hunting = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
inventory.setStack(0, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts"));
inventory.setStack(1, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts"));
inventory.setStack(SLOT_INFO, createHomeInfoButton(player));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
return inventory;
}
private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) {
inventory.setStack(index, createContractStack(player, contracts.get(index)));
}
inventory.setStack(SLOT_HOME, createBackButton());
inventory.setStack(SLOT_PREVIOUS, createPageButton(view.page(), totalPages(player, view.categoryKey()), true));
inventory.setStack(SLOT_INFO, createCategoryInfoButton(player, view.categoryKey()));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
inventory.setStack(SLOT_NEXT, createPageButton(view.page(), totalPages(player, view.categoryKey()), false));
return inventory;
}
private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) {
if (slotIndex == 0) {
openCategory(player, "mining", 0);
} else if (slotIndex == 1) {
openCategory(player, "hunting", 0);
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openHome(player, view.page());
}
}
private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) {
if (slotIndex < ITEM_SLOT_COUNT) {
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
if (slotIndex >= contracts.size()) {
return;
}
ContractDefinition contract = contracts.get(slotIndex);
if (select(player, contract)) {
player.sendMessage(net.minecraft.text.Text.literal("Selected contract: " + contract.name()).formatted(Formatting.GREEN), false);
}
openCategory(player, view.categoryKey(), view.page());
return;
}
if (slotIndex == SLOT_HOME) {
openContracts(player);
} else if (slotIndex == SLOT_PREVIOUS) {
openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1));
} else if (slotIndex == SLOT_NEXT) {
openCategory(player, view.categoryKey(), Math.min(totalPages(player, view.categoryKey()) - 1, view.page() + 1));
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openCategory(player, view.categoryKey(), view.page());
}
}
private ItemStack createContractStack(ServerPlayerEntity player, ContractDefinition contract) {
long progress = progress(player, contract);
List<Text> lore = new ArrayList<>();
if (!contract.description().isBlank()) {
lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY));
}
lore.add(Text.literal("Target: " + contract.targetName()).formatted(Formatting.AQUA));
lore.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GOLD));
lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
ContractDefinition selected = selected(player);
if (selected != null && selected.id().equalsIgnoreCase(contract.id())) {
lore.add(Text.literal("Selected").formatted(Formatting.AQUA));
}
return createPreviewStack(contract.iconItemId(), contract.name(), lore);
}
private ItemStack createCategoryButton(String name, String iconItemId, int count, String description) {
return createPreviewStack(iconItemId, name, List.of(
Text.literal(description).formatted(Formatting.GRAY),
Text.literal("Contracts: " + count).formatted(Formatting.AQUA)
));
}
private ItemStack createHomeInfoButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
List<Text> lore = new ArrayList<>();
lore.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
lore.add(Text.literal("Selected: " + (selected == null ? "None" : selected.name())).formatted(Formatting.AQUA));
if (selected != null) {
lore.add(Text.literal("Progress: " + progress(player, selected) + "/" + selected.amountRequired()).formatted(Formatting.GRAY));
}
return createPreviewStack("minecraft:nether_star", "Contract Browser", lore);
}
private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = contractsFor(player, categoryKey);
return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of(
Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA),
Text.literal("Selected: " + (selected(player) == null ? "None" : selected(player).name())).formatted(Formatting.GOLD)
));
}
private ItemStack createBackButton() {
return createPreviewStack("minecraft:barrier", "Back", List.of(Text.literal("Return to the contract browser.").formatted(Formatting.GRAY)));
}
private ItemStack createClearButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return createPreviewStack("minecraft:redstone_torch", "Clear Selected", List.of(
Text.literal(selected == null ? "No contract selected." : "Clear: " + selected.name()).formatted(Formatting.GRAY),
Text.literal("Remove your active contract.").formatted(Formatting.DARK_GRAY)
));
}
private ItemStack createPageButton(int page, int totalPages, boolean previous) {
boolean available = previous ? page > 0 : page < totalPages - 1;
String label = previous ? "Previous Page" : "Next Page";
return createPreviewStack("minecraft:arrow", label, List.of(
Text.literal("Page " + (page + 1) + " of " + totalPages).formatted(Formatting.GRAY),
Text.literal(available ? "Click to switch pages." : "No more pages in this direction.").formatted(available ? Formatting.AQUA : Formatting.DARK_GRAY)
));
}
private List<ContractDefinition> contractsFor(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = switch (categoryKey) {
case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
default -> List.of();
};
return contracts.stream()
.filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id()))
.toList();
}
private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) {
int perPage = Math.max(1, ITEM_SLOT_COUNT);
int totalPages = Math.max(1, (int) Math.ceil(contracts.size() / (double) perPage));
int actualPage = Math.max(0, Math.min(totalPages - 1, page));
int from = actualPage * perPage;
int to = Math.min(contracts.size(), from + perPage);
return contracts.subList(from, to);
}
private int totalPages(ServerPlayerEntity player, String categoryKey) {
return Math.max(1, (int) Math.ceil(contractsFor(player, categoryKey).size() / (double) ITEM_SLOT_COUNT));
}
private String categoryLabel(String categoryKey) {
return switch (categoryKey) {
case "mining" -> "Mining Contracts";
case "hunting" -> "Hunting Contracts";
default -> "Contracts";
};
}
private SimpleInventory filledInventory(int rows) {
SimpleInventory inventory = new SimpleInventory(rows * 9);
ItemStack filler = createPreviewStack(bundleSupplier.get().config().shop().fillerItemId(), " ", List.of());
filler.remove(DataComponentTypes.LORE);
for (int slot = 0; slot < inventory.size(); slot++) {
inventory.setStack(slot, filler.copy());
}
return inventory;
}
private ItemStack createPreviewStack(String itemId, String name, List<Text> lore) {
ItemStack stack = rewardService.createPreviewStack(itemId, name, lore);
return stack;
}
private record HomeView(int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
private record CategoryView(String categoryKey, int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
public sealed interface View permits HomeView, CategoryView {
int rows();
}
}
@@ -0,0 +1,64 @@
package com.g2806.soulsteal.contract;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.screen.ScreenHandler;
import net.minecraft.screen.ScreenHandlerType;
import net.minecraft.screen.slot.Slot;
import net.minecraft.screen.slot.SlotActionType;
import net.minecraft.server.network.ServerPlayerEntity;
/**
* Generic container handler that turns contract GUI clicks into selection actions.
*/
public final class ContractScreenHandler extends ScreenHandler {
private final SimpleInventory inventory;
private final ContractGuiService guiService;
private final ContractGuiService.View view;
public ContractScreenHandler(int syncId, PlayerInventory playerInventory, ContractGuiService guiService, ContractGuiService.View view) {
super(view.rows() == 3 ? ScreenHandlerType.GENERIC_9X3 : ScreenHandlerType.GENERIC_9X6, syncId);
this.guiService = guiService;
this.view = view;
this.inventory = guiService.createInventory((ServerPlayerEntity) playerInventory.player, view);
int rows = view.rows();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < 9; column++) {
int slotIndex = row * 9 + column;
this.addSlot(new Slot(inventory, slotIndex, 8 + column * 18, 18 + row * 18));
}
}
int playerInventoryY = 18 + rows * 18 + 14;
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column + row * 9 + 9, 8 + column * 18, playerInventoryY + row * 18));
}
}
int hotbarY = playerInventoryY + 58;
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column, 8 + column * 18, hotbarY));
}
}
@Override
public ItemStack quickMove(PlayerEntity player, int slot) {
return ItemStack.EMPTY;
}
@Override
public boolean canUse(PlayerEntity player) {
return true;
}
@Override
public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) {
if (player instanceof ServerPlayerEntity serverPlayer && slotIndex >= 0 && slotIndex < inventory.size()) {
guiService.handleClick(serverPlayer, view, slotIndex);
}
}
}
@@ -0,0 +1,6 @@
package com.g2806.soulsteal.contract;
public enum ContractType {
MINING,
HUNTING
}
@@ -19,11 +19,14 @@ public final class SoulStealData {
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, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = 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<>();
public SoulStealData normalize() {
public SoulStealData normalize() {
if (souls == null) {
souls = new HashMap<>();
}
@@ -45,41 +48,102 @@ public final class SoulStealData {
if (playerNames == null) {
playerNames = new HashMap<>();
}
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
}
return this;
}
public Map<String, Long> souls() {
if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>();
}
if (selectedContracts == null) {
selectedContracts = new HashMap<>();
}
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
if (completedContracts == null) {
completedContracts = new HashMap<>();
}
return this;
}
/**
* Returns the persistent soul balance table keyed by player UUID string.
*
* @return mutable soul balance map
*/
public Map<String, Long> souls() {
return souls;
}
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() {
}
/**
* Returns the internal permission fallback table.
*
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
public Map<String, Long> bountyPlacementCooldowns() {
}
/**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns;
}
public Map<String, String> playerNames() {
}
/**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames;
}
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility;
}
}
}
/**
* 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;
}
}
@@ -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,123 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.contract.ContractCatalog;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity;
public final class ContractService {
private final Supplier<ContractCatalog> catalogSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
public ContractService(Supplier<ContractCatalog> catalogSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.catalogSupplier = catalogSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
}
public Optional<ContractDefinition> selectedContract(UUID playerUuid) {
String contractId = dataStore.data().selectedContracts().get(key(playerUuid));
if (contractId == null) {
return Optional.empty();
}
return catalogSupplier.get().contract(contractId);
}
public boolean selectContract(ServerPlayerEntity player, String contractId) {
Optional<ContractDefinition> contract = catalogSupplier.get().contract(contractId);
if (contract.isEmpty()) {
return false;
}
if (!contract.get().repeatable() && hasCompletedContract(player.getUuid(), contract.get().id())) {
return false;
}
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
dataStore.data().contractProgress().remove(key(player.getUuid()));
saveQuietly();
return true;
}
public void clearContract(UUID playerUuid) {
dataStore.data().selectedContracts().remove(key(playerUuid));
dataStore.data().contractProgress().remove(key(playerUuid));
saveQuietly();
}
public long progress(UUID playerUuid) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.values().stream().mapToLong(Long::longValue).sum();
}
public long progress(UUID playerUuid, String contractId) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.getOrDefault(contractId, 0L);
}
public boolean hasCompletedContract(UUID playerUuid, String contractId) {
return dataStore.data().completedContracts()
.getOrDefault(key(playerUuid), java.util.Set.of())
.contains(contractId);
}
public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId);
}
public void recordHunting(ServerPlayerEntity player, String entityId) {
record(player, ContractType.HUNTING, entityId);
}
private void record(ServerPlayerEntity player, ContractType type, String targetId) {
Optional<ContractDefinition> selected = selectedContract(player.getUuid());
if (selected.isEmpty() || !catalogSupplier.get().enabled()) {
return;
}
ContractDefinition contract = selected.get();
if (contract.type() != type || !contract.targetId().equalsIgnoreCase(targetId)) {
return;
}
String playerKey = key(player.getUuid());
Map<String, Long> progressMap = dataStore.data().contractProgress().computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>());
long updated = progressMap.getOrDefault(contract.id(), 0L) + 1L;
if (updated >= contract.amountRequired()) {
progressMap.remove(contract.id());
soulService.addSouls(player.getUuid(), contract.reward());
player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+"
+ contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false);
if (!contract.repeatable()) {
dataStore.data().completedContracts()
.computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>())
.add(contract.id());
}
if (!contract.repeatable()) {
dataStore.data().selectedContracts().remove(playerKey);
}
} else {
progressMap.put(contract.id(), updated);
}
saveQuietly();
}
private void saveQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist contract data.", exception);
}
}
private static String key(UUID playerUuid) {
return playerUuid.toString();
}
}
@@ -1,9 +1,10 @@
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 java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
@@ -24,46 +25,66 @@ 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.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 +100,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 +128,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());
@@ -194,21 +240,31 @@ public final class HudService {
}
}
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
long remainingSeconds = activeBounties.stream()
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
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(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
long progress = contractService.progress(player.getUuid(), contract.id());
lines.add(Text.literal("Contract: " + contract.name()).formatted(Formatting.AQUA));
lines.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GRAY));
});
if (!activeBounties.isEmpty()) {
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
long remainingSeconds = activeBounties.stream()
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
lines.add(Text.literal("Bounties: " + activeBounties.size()).formatted(Formatting.RED));
lines.add(Text.literal("Wanted Value: " + totalValue).formatted(Formatting.GOLD));
lines.add(Text.literal("Wanted Time: " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(Formatting.DARK_RED));
}
return lines;
}
private void clearSidebar(ServerPlayerEntity player) {
@@ -235,14 +291,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,
Text.literal(configSupplier.get().hud().scoreboard().title()).formatted(Formatting.DARK_AQUA),
ScoreboardCriterion.RenderType.INTEGER,
false,
BlankNumberFormat.INSTANCE
);
}
private void rememberPlayer(ServerPlayerEntity player) {
@@ -283,4 +339,4 @@ public final class HudService {
public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) {
}
}
}
@@ -22,11 +22,19 @@ import net.minecraft.server.network.ServerPlayerEntity;
public final class PermissionService {
private final SoulStealDataStore dataStore;
public PermissionService(SoulStealDataStore dataStore) {
this.dataStore = dataStore;
}
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
public PermissionService(SoulStealDataStore dataStore) {
this.dataStore = dataStore;
}
/**
* Checks whether a command source has a permission node.
*
* @param source permission subject
* @param permission node to check
* @param defaultLevel fallback operator level when no permission backend is available
* @return {@code true} if the source is allowed to use the permission
*/
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) {
return true;
}
@@ -37,18 +45,34 @@ public final class PermissionService {
}
return source.getPlayer() == null || defaultLevel <= 0;
}
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) {
}
/**
* Checks whether a source has any permission from the provided set.
*
* @param source permission subject
* @param defaultLevel fallback operator level when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) {
return true;
}
}
return false;
}
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) {
}
/**
* Checks whether a player has a permission node.
*
* @param player permission subject
* @param permission node to check
* @param defaultValue fallback value when no permission backend is available
* @return {@code true} if the player is allowed to use the permission
*/
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) {
if (hasStoredPermission(player.getUuid(), permission)) {
return true;
}
@@ -59,18 +83,35 @@ public final class PermissionService {
}
return defaultValue;
}
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) {
}
/**
* Checks whether a player has any permission from the provided set.
*
* @param player permission subject
* @param defaultValue fallback value when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) {
for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) {
return true;
}
}
return false;
}
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
}
/**
* Grants a permission through LuckPerms if available and optionally stores a fallback copy.
*
* @param playerUuid player receiving the permission
* @param permission node to grant or revoke
* @param value desired node value
* @param storeFallback whether to persist the value in Soul Steal's internal store
* @return grant outcome and backend details
*/
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false;
@@ -194,4 +235,4 @@ public final class PermissionService {
public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) {
}
}
}
@@ -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) {
}
}
}
@@ -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);
}
}
}
@@ -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));
}
}
}