8 Commits

Author SHA1 Message Date
darwincereska 13fd72d304 feat: revamped hud
Release / build-and-release (push) Successful in 1m48s
2026-05-10 15:54:00 -04:00
darwincereska dce135c857 feat: add contract cooldowns, mining target matches, and rank command rewards 2026-05-10 15:44:34 -04:00
darwincereska 7f3bb68719 bump(version): fixed version number
Release / build-and-release (push) Successful in 1m16s
2026-05-09 13:51:58 -04:00
darwincereska fa52df1d39 fix(config): fixed indentation bug
Release / build-and-release (push) Has been cancelled
2026-05-09 13:49:28 -04:00
darwincereska 32165b87e7 feat(ci): add gitea release workflow for tag builds
Release / build-and-release (push) Successful in 2m38s
2026-05-09 11:48:20 -04:00
darwincereska 5f0085d2ce fix(contracts): wire home-page clear action and hide completed one-time contracts 2026-05-09 11:44:39 -04:00
darwincereska 024630d96c feat(contracts): add server-side contract browser and progress tracking 2026-05-09 11:16:46 -04:00
darwincereska 84e05eff7f feat(commands): add admin tracker compass grant command and document it
- add /souls tracker give <player> <target> as an admin-only command
- reuse the existing TrackerCompassService so the command uses the same tracker behavior and duration as kill-granted compasses
- gate the command behind soulsteal.admin
- update README.md command reference
- update docs/PROJECT.md with tracker compass behavior and the new admin command
- keep the implementation scoped to the command layer without changing the tracker service API
2026-05-08 15:42:12 -04:00
37 changed files with 3006 additions and 781 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Build jar
run: ./gradlew build
- name: Find release artifact
id: artifact
shell: bash
run: |
set -euo pipefail
jar=$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' | head -n 1)
if [ -z "${jar}" ]; then
echo "No jar artifact found in build/libs" >&2
exit 1
fi
echo "path=${jar}" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$jar")" >> "$GITHUB_OUTPUT"
- name: Create Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "Missing GITEA_TOKEN secret" >&2
exit 1
fi
api="${GITHUB_SERVER_URL}/api/v1"
repo="${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}"
release_name="${tag}"
jar_path="${{ steps.artifact.outputs.path }}"
jar_name="${{ steps.artifact.outputs.name }}"
payload=$(jq -n \
--arg tag_name "$tag" \
--arg name "$release_name" \
--arg body "Automated release for $tag" \
'{tag_name:$tag_name,name:$name,body:$body,draft:false,prerelease:false}')
release=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "$payload" \
"${api}/repos/${repo}/releases")
release_id=$(printf '%s' "$release" | jq -r '.id')
upload_url="${api}/repos/${repo}/releases/${release_id}/assets?name=${jar_name}"
curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${jar_path}" \
"${upload_url}"
+1
View File
@@ -2,6 +2,7 @@
.gradle/ .gradle/
build/ build/
out/ out/
logs/
# Loom / Mod Dev Gradle caches # Loom / Mod Dev Gradle caches
.loom-cache/ .loom-cache/
+18 -11
View File
@@ -1,6 +1,8 @@
# Soul Steal # Soul Steal
Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bounties, tracker compasses, and a vanilla-compatible soul shop. Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bounties, tracker compasses, and a vanilla-compatible soul shop.
Full project documentation is available in [docs/PROJECT.md](docs/PROJECT.md).
## Requirements ## Requirements
@@ -13,7 +15,7 @@ Soul Steal is a server-side Fabric mod that adds a configurable soul economy, bo
## How It Works ## 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 ## 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 shop [category]` | All players | Opens the soul shop GUI, optionally on a specific category. |
| `/souls bounty place <player> <amount> [durationSeconds]` | All players | Places a timed bounty on another player. | | `/souls bounty place <player> <amount> [durationSeconds]` | All players | Places a timed bounty on another player. |
| `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. | | `/souls bounty list [player]` | All players | Lists active bounties globally or for one target. |
| `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. | | `/souls scoreboard [toggle|on|off]` | All players | Toggles the optional Soul Steal sidebar HUD for your player. |
| `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. | | `/souls top [page]` | All players | Shows the soul leaderboard using the configured page size. |
| `/souls reload` | Admins / `soulsteal.admin` or `soulsteal.admin.reload` | Reloads `config.yml` and `shop.yml` without restarting the server. | | `/souls tracker give <player> <target>` | Admins / `soulsteal.admin` | Gives a tracker compass to one player that points at another player. |
| `/souls set|add|take <player> <amount>` | Admins / `soulsteal.admin` or the matching `soulsteal.admin.balance.*` node | Directly manages a player's soul balance. | | `/souls 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 ## Configuration
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
| `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. | | `config/soulsteal/config.yml` | Economy values, death penalties, bounty limits, HUD toggles, leaderboard size, bossbar text, and command permission nodes. |
| `config/soulsteal/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. | | `config/soulsteal/shop.yml` | Shop categories, GUI entries, prices, cooldowns, reward display names, and optional custom-amount settings for item listings. |
| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. | | `config/soulsteal/catalog.yml` | Grouped mining and hunting contract sections, internal ids, player-facing names, icons, targets, progress amounts, rewards, and repeatable/one-time behavior. |
| `config/soulsteal/soulsteal-data.json` | Persistent balances, active bounties, cooldowns, unlocks, internal permission fallback storage, saved player names, and scoreboard preferences. |
+21 -13
View File
@@ -20,15 +20,19 @@ repositories {
} }
} }
dependencies { dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}" minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}" implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}" include "org.yaml:snakeyaml:${project.snakeyaml_version}"
}
testImplementation platform("org.junit:junit-bom:5.11.4")
testImplementation "org.junit.jupiter:junit-jupiter"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
processResources { processResources {
inputs.property 'version', project.version inputs.property 'version', project.version
@@ -38,9 +42,13 @@ processResources {
} }
} }
tasks.withType(JavaCompile).configureEach { tasks.withType(JavaCompile).configureEach {
it.options.release = 21 it.options.release = 21
} }
test {
useJUnitPlatform()
}
java { java {
withSourcesJar() withSourcesJar()
@@ -65,4 +73,4 @@ publishing {
repositories { repositories {
} }
} }
+341
View File
@@ -0,0 +1,341 @@
# Soul Steal Project Documentation
## Overview
Soul Steal is a server-side Fabric mod that implements a soul economy, player bounties, tracker compasses, a vanilla-style shop GUI, optional HUD elements, and permission-backed admin controls.
The mod is built to run entirely on the server. Players do not need a client mod to use the commands or the shop.
## Runtime Architecture
The entry point is [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java), which performs four jobs:
1. Loads configuration from `config/soulsteal/config.yml`.
2. Loads persistent player data from `config/soulsteal/soulsteal-data.json`.
3. Instantiates the feature services.
4. Registers command, tick, join, death, and disconnect handlers.
The main services are:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java): balance reads and mutations.
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java): bounty placement, expiry, and kill claims.
- [`TrackerCompassService`](../src/main/java/com/g2806/soulsteal/service/TrackerCompassService.java): temporary tracking compasses for player kills.
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java): server-side shop GUI and purchases.
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java): validates and grants rewards from shop entries.
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java): permission lookups and fallback storage.
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java): scoreboard sidebar, leaderboard, and bounty bossbar.
Shared utilities:
- [`SoulTexts`](../src/main/java/com/g2806/soulsteal/util/SoulTexts.java): formatted feedback, success, warning, and error messages.
- [`DurationFormatter`](../src/main/java/com/g2806/soulsteal/util/DurationFormatter.java): human-readable duration strings.
## Feature Summary
### Soul Economy
Players have a soul balance stored per UUID.
- Killing another player can grant souls.
- Dying can remove souls using configurable flat and percentage penalties.
- Transfers between players can be enabled or disabled in config.
- Admin commands can set, add, or remove balances directly.
Implemented by:
- [`SoulService`](../src/main/java/com/g2806/soulsteal/service/SoulService.java)
- [`SoulCommandRegistrar`](../src/main/java/com/g2806/soulsteal/command/SoulCommandRegistrar.java)
### Bounties
Players can place timed bounties on other players using their souls as the payment source.
Behavior:
- Placement is bounded by min/max value, min/max duration, cooldowns, and active bounty limits.
- Killing the target claims all active bounties on that target.
- If a bounty expires, the target receives a configured survivor payout.
- Bounties are cleared if the target dies to non-player damage and no killer claims them.
Implemented by:
- [`BountyService`](../src/main/java/com/g2806/soulsteal/service/BountyService.java)
- [`SoulStealMod`](../src/main/java/com/g2806/soulsteal/SoulStealMod.java)
### Tracker Compasses
When enabled, killing a player grants a temporary tracker compass.
Behavior:
- The compass tracks the killed player using lodestone-tracker data.
- The item stores target UUID, target name, and expiration time in custom NBT.
- Expired compasses are removed during server ticks.
- Optional behavior can remove the compass if the target is offline.
Admins can also grant a tracker compass directly with `/souls tracker give <player> <target>`. This uses the same tracker data and duration as the kill-granted version.
### Contracts
Contracts let players earn souls without PvP by completing mining and hunting goals.
Behavior:
- Players pick one active contract at a time.
- Mining contracts track block breaks against a configured block id.
- Hunting contracts track kills against a configured entity id.
- The selected contract and its progress appear in the HUD sidebar.
- Completing a contract pays souls automatically.
- One-time contracts disappear from the browser after completion and cannot be selected again.
- The contract browser uses `catalog.yml`, where the YAML key is the internal contract id and `name` is the player-facing label.
- Contract entries are organized under `contracts: mining:` and `contracts: hunting:` sections for readability, while still using per-entry ids.
- The contract browser uses a bottom control row like the shop, including page navigation anchored to the bottom of the inventory.
- The contract browser includes a clear-selection action on the home page and inside contract categories so players can remove their active contract from the GUI.
Implemented by:
- [`ContractService`](../src/main/java/com/g2806/soulsteal/service/ContractService.java)
- [`ContractGuiService`](../src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java)
- [`ContractCatalog`](../src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java)
- [`ContractDefinition`](../src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java)
### Shop
The shop is a chest-based GUI rendered entirely with vanilla screen handler APIs.
Behavior:
- The home view lists categories with pagination.
- Category views list entries with pagination.
- Item entries can be direct purchases or quantity-select purchases.
- Purchases can be repeatable or single-unlock.
- Cooldowns can be stored per entry per player.
- Rewards can include items, effects, commands, and permissions.
Implemented by:
- [`ShopService`](../src/main/java/com/g2806/soulsteal/service/ShopService.java)
- [`RewardService`](../src/main/java/com/g2806/soulsteal/service/RewardService.java)
- [`SoulShopScreenHandler`](../src/main/java/com/g2806/soulsteal/shop/SoulShopScreenHandler.java)
- [`ShopCatalog`](../src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java)
### HUD
The HUD layer is optional and configurable per player.
Behavior:
- Scoreboard sidebar can be enabled globally and toggled per player.
- The sidebar title uses the same dark aqua styling as the mod's chat prefix.
- Contract and bounty rows only appear while the player has an active selected contract or active bounties.
- Balance, contract, and bounty rows use color to make the sidebar easier to scan.
- Leaderboard pages are built from stored player names and balance values.
- Wanted-player bossbars show bounty value and remaining time.
Implemented by:
- [`HudService`](../src/main/java/com/g2806/soulsteal/service/HudService.java)
### Permissions
The mod checks permissions in two layers:
1. External permission backends through the Fabric permissions API and optional LuckPerms integration.
2. Internal fallback storage persisted in `soulsteal-data.json`.
This allows reward-granted permissions and admin nodes to keep working even without an external permission mod.
Implemented by:
- [`PermissionService`](../src/main/java/com/g2806/soulsteal/service/PermissionService.java)
## Command Reference
The root command has two aliases:
- `/souls`
- `/soul`
### Player Commands
- `/souls`
- Shows your current balance.
- `/souls balance`
- Same as `/souls`.
- `/souls balance <player>`
- Shows another players balance.
- `/souls pay <player> <amount>`
- Transfers souls to another player.
- `/souls shop [category]`
- Opens the shop GUI.
- `/souls bounty place <player> <amount> [durationSeconds]`
- Places a bounty.
- `/souls bounty list [player]`
- Lists active bounties.
- `/souls scoreboard`
- Shows scoreboard visibility state.
- `/souls scoreboard toggle`
- Toggles the players scoreboard preference.
- `/souls scoreboard on`
- Forces scoreboard visibility on.
- `/souls scoreboard off`
- Forces scoreboard visibility off.
- `/souls top [page]`
- Shows leaderboard pages.
- `/souls tracker give <player> <target>`
- Gives a tracker compass to one player that points at another player.
- `/souls contracts`
- Opens the contract browser GUI.
- `/souls contracts selected`
- Shows the currently selected contract and progress.
- `/souls contracts clear`
- Clears your selected contract.
### Admin Commands
- `/souls reload`
- Reloads config and shop definitions.
- `/souls set <player> <amount>`
- Sets a balance directly.
- `/souls add <player> <amount>`
- Adds souls to a balance.
- `/souls take <player> <amount>`
- Removes souls from a balance.
Permission defaults and node names are configured in `config.yml`.
## Configuration Files
### `config/soulsteal/config.yml`
Primary configuration file. It controls:
- Economy values
- Death penalties
- Transfer rules
- Bounty limits and payouts
- Tracker compass behavior
- Shop UI defaults
- HUD toggles and titles
- Permission node names
The schema is defined in [`SoulStealConfig`](../src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java).
### `config/soulsteal/shop.yml`
Shop catalog definition. It controls:
- Shop title and layout
- Category list
- Entry pricing
- Entry cooldowns
- Reward definitions
- Optional custom quantity selector behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/catalog.yml`
Contract catalog definition. It controls:
- Grouped contract lists under `mining` and `hunting`
- Internal contract ids from each entry `id`
- Player-facing contract names
- Icon item ids
- Target block or mob ids
- Required amounts and rewards
- Repeatable behavior
The catalog is loaded through [`ConfigBundle`](../src/main/java/com/g2806/soulsteal/config/ConfigBundle.java).
### `config/soulsteal/soulsteal-data.json`
Persistent runtime data. It stores:
- Player soul balances
- Active bounties
- Bounty placement cooldowns
- Shop unlocks
- Shop purchase cooldowns
- Permission fallback grants
- Player name history
- Scoreboard visibility preferences
- Selected contracts
- Contract progress
Loaded and saved by [`SoulStealDataStore`](../src/main/java/com/g2806/soulsteal/data/SoulStealDataStore.java).
## Data Flow
### Player Join
When a player joins:
1. Their name is recorded in persistent data.
2. Their HUD state is refreshed.
3. Any configured scoreboard or bossbar state is pushed to them.
### HUD Sidebar
When the sidebar is visible:
1. The title is rendered in dark aqua.
2. The soul balance row is always shown.
3. Contract rows are only shown if the player has an active selected contract.
4. Bounty rows are only shown if the player currently has active bounties.
5. The visible rows use color to distinguish balance, contract, and bounty information.
### Player Kill
When one player kills another player:
1. The killer receives kill reward souls if enabled.
2. Any matching bounties on the victim are claimed.
3. The killer receives a tracker compass if the tracker feature is enabled.
4. Any matching hunting contract progresses if the killer has one selected.
### Block Break
When a player breaks a block:
1. Any matching mining contract progresses if the player has one selected.
### Player Death
When a player dies:
1. The configured death penalty is applied.
2. If the death was not caused by another player, unclaimed bounties on the victim can be cleared.
### Server Tick
On server tick:
1. Tracker compasses are updated and expired compasses are removed.
2. Bounty expirations are processed once per second.
3. HUD elements are refreshed.
### Server Shutdown
On shutdown:
- Persistent data is saved to disk.
## Project Structure
- `src/main/java/com/g2806/soulsteal/`
- Bootstrap, commands, services, config, data, shop, and utilities.
- `src/main/java/com/g2806/soulsteal/contract/`
- Contract catalog, GUI, and screen handling classes.
- `src/main/resources/`
- Fabric metadata and resource files.
- `build.gradle`
- Fabric Loom build configuration.
## Development Notes
- The mod targets Java 21.
- It is structured as a server-only feature set, so most behavior is driven by server lifecycle events.
- Persistence is intentionally simple: one JSON data file and YAML-driven configuration.
- External integrations are reflected through reflection-based checks so the mod can run without hard dependencies on permission backends.
- Contract configuration is separated into `catalog.yml` so contract entries can be expanded without changing the main economy config.
+1 -1
View File
@@ -10,7 +10,7 @@ loom_version=1.16.1
fabric_api_version=0.141.3+1.21.11 fabric_api_version=0.141.3+1.21.11
# Mod Properties # Mod Properties
mod_version=0.3.0 mod_version=0.5.0
maven_group=com.g2806.soulsteal maven_group=com.g2806.soulsteal
archives_base_name=soul-steal archives_base_name=soul-steal
@@ -1,196 +1,330 @@
package com.g2806.soulsteal; package com.g2806.soulsteal;
import com.g2806.soulsteal.command.SoulCommandRegistrar; import com.g2806.soulsteal.command.SoulCommandRegistrar;
import com.g2806.soulsteal.config.ConfigBundle; import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.config.SoulStealConfig; import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.service.BountyService; import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService; import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.PermissionService; import com.g2806.soulsteal.contract.ContractGuiService;
import com.g2806.soulsteal.service.RewardService; import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.ShopService; import com.g2806.soulsteal.service.PermissionService;
import com.g2806.soulsteal.service.SoulService; import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.TrackerCompassService; import com.g2806.soulsteal.service.ShopService;
import com.g2806.soulsteal.util.SoulTexts; import com.g2806.soulsteal.service.SoulService;
import java.io.IOException; import com.g2806.soulsteal.service.TrackerCompassService;
import java.io.UncheckedIOException; import com.g2806.soulsteal.util.SoulTexts;
import java.nio.file.Path; import java.io.IOException;
import net.fabricmc.api.ModInitializer; import java.io.UncheckedIOException;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import java.nio.file.Path;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents; import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.entity.event.v1.ServerEntityCombatEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.entity.damage.DamageSource; import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.minecraft.server.MinecraftServer; import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.server.network.ServerPlayerEntity; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.text.Text; import net.fabricmc.loader.api.FabricLoader;
import org.slf4j.Logger; import net.minecraft.entity.damage.DamageSource;
import org.slf4j.LoggerFactory; import net.minecraft.item.BlockItem;
import net.minecraft.item.ItemStack;
/** import net.minecraft.util.math.BlockPos;
* Entrypoint for the Soul Steal mod. import net.minecraft.registry.Registries;
* import net.minecraft.util.ActionResult;
* <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the import net.minecraft.util.math.Direction;
* single bootstrap location for lifecycle setup and shared constants.</p> import net.minecraft.server.MinecraftServer;
*/ import net.minecraft.server.network.ServerPlayerEntity;
public final class SoulStealMod implements ModInitializer { import org.slf4j.Logger;
public static final String MOD_ID = "soulsteal"; import org.slf4j.LoggerFactory;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
/**
private Path configDirectory; * Entrypoint for the Soul Steal mod.
private ConfigBundle configBundle; *
private SoulStealDataStore dataStore; * <p>The bulk of the feature wiring is added in subsequent modules, but this class remains the
private SoulService soulService; * single bootstrap location for lifecycle setup and shared constants.</p>
private PermissionService permissionService; */
private BountyService bountyService; public final class SoulStealMod implements ModInitializer {
private RewardService rewardService; public static final String MOD_ID = "soulsteal";
private TrackerCompassService trackerCompassService; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private ShopService shopService;
private HudService hudService; private Path configDirectory;
private ConfigBundle configBundle;
@Override private SoulStealDataStore dataStore;
public void onInitialize() { private SoulService soulService;
LOGGER.info("Initializing Soul Steal"); private PermissionService permissionService;
configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID); private BountyService bountyService;
private ContractService contractService;
try { private ContractGuiService contractGuiService;
configBundle = ConfigBundle.load(configDirectory); private RewardService rewardService;
dataStore = new SoulStealDataStore(configDirectory); private TrackerCompassService trackerCompassService;
dataStore.load(); private ShopService shopService;
} catch (IOException exception) { private HudService hudService;
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception); /**
} * Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers.
permissionService = new PermissionService(dataStore); */
soulService = new SoulService(this::config, dataStore); @Override
bountyService = new BountyService(this::config, dataStore, soulService); public void onInitialize() {
rewardService = new RewardService(permissionService, soulService); LOGGER.info("Initializing Soul Steal");
trackerCompassService = new TrackerCompassService(this::config); configDirectory = FabricLoader.getInstance().getConfigDir().resolve(MOD_ID);
shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
hudService = new HudService(this::config, dataStore, soulService, bountyService); try {
configBundle = ConfigBundle.load(configDirectory);
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this)); dataStore = new SoulStealDataStore(configDirectory);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player)); dataStore.load();
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player)); } catch (IOException exception) {
ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> { throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) { }
onPlayerKilledOtherPlayer(killer, victim);
} permissionService = new PermissionService(dataStore, () -> this.config().permissions());
}); soulService = new SoulService(this::config, dataStore);
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> { bountyService = new BountyService(this::config, dataStore, soulService);
if (entity instanceof ServerPlayerEntity player) { contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
onPlayerDeath(player, damageSource); rewardService = new RewardService(permissionService, soulService);
} contractGuiService = new ContractGuiService(this::bundle, contractService, rewardService, soulService);
}); trackerCompassService = new TrackerCompassService(this::config);
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); shopService = new ShopService(this::bundle, soulService, rewardService, dataStore);
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData()); hudService = new HudService(this::config, dataStore, soulService, bountyService, contractService);
}
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> SoulCommandRegistrar.register(dispatcher, this));
private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) { ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> hudService.handlePlayerJoin(handler.player));
long reward = config().economy().killReward(); ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> hudService.handlePlayerDisconnect(handler.player));
if (reward > 0L) { ServerEntityCombatEvents.AFTER_KILLED_OTHER_ENTITY.register((level, entity, killedEntity, damageSource) -> {
soulService.addSouls(killer.getUuid(), reward); if (entity instanceof ServerPlayerEntity killer && killedEntity instanceof ServerPlayerEntity victim) {
killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false); onPlayerKilledOtherPlayer(killer, victim);
} }
});
BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid()); ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (bountyClaim.claimedAny()) { if (entity instanceof ServerPlayerEntity player) {
MinecraftServer server = killer.getCommandSource().getServer(); onPlayerDeath(player, damageSource);
server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false); return;
} }
trackerCompassService.giveTrackerCompass(killer, victim); if (damageSource.getAttacker() instanceof ServerPlayerEntity killer) {
} contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString());
}
private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) { });
SoulService.SoulChange penalty = soulService.applyDeathPenalty(player.getUuid()); UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
if (penalty.delta() < 0L) { if (!(player instanceof ServerPlayerEntity serverPlayer)) {
player.sendMessage(SoulTexts.warning("You lost " + (-penalty.delta()) + " souls on death. Balance: " + penalty.newBalance()), false); return ActionResult.PASS;
} }
if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) { ItemStack stack = serverPlayer.getStackInHand(hand);
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid()); if (!(stack.getItem() instanceof BlockItem blockItem)) {
if (!removedBounties.isEmpty()) { return ActionResult.PASS;
player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false); }
}
} // Match block placement by the item the player is holding, not the broken state.
} // That lets us mark player-placed ore blocks even when silk touch preserves the block.
if (!contractService.matchesMiningTarget(blockItem.getBlock())) {
private void onServerTick(MinecraftServer server) { return ActionResult.PASS;
trackerCompassService.tick(server); }
if (server.getTicks() % 20 != 0) {
return; String key = blockKey(world, placedBlockPos(hitResult.getBlockPos(), hitResult.getSide()));
} dataStore.data().playerPlacedMiningTargets().add(key);
saveDataQuietly();
long now = System.currentTimeMillis(); return ActionResult.PASS;
for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) { });
ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) { PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false); if (player instanceof ServerPlayerEntity serverPlayer) {
} String key = blockKey(world, pos);
// Ignore blocks we recorded as player-placed targets; those should not advance mining.
if (payout.reward() > 0L) { if (dataStore.data().playerPlacedMiningTargets().remove(key)) {
server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false); saveDataQuietly();
} return;
} }
contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
hudService.tick(server, now); }
} });
ServerTickEvents.END_SERVER_TICK.register(this::onServerTick);
public boolean reloadConfiguration() { ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveData());
try { }
configBundle = ConfigBundle.load(configDirectory);
return true; private void onPlayerKilledOtherPlayer(ServerPlayerEntity killer, ServerPlayerEntity victim) {
} catch (IOException exception) { long reward = config().economy().killReward();
LOGGER.error("Failed to reload Soul Steal configuration.", exception); if (reward > 0L) {
return false; soulService.addSouls(killer.getUuid(), reward);
} killer.sendMessage(SoulTexts.success("You gained " + reward + " souls for killing " + victim.getName().getString() + "."), false);
} }
private void saveData() { BountyService.ClaimBountyResult bountyClaim = bountyService.claimForKill(killer.getUuid(), victim.getUuid());
try { if (bountyClaim.claimedAny()) {
dataStore.save(); MinecraftServer server = killer.getCommandSource().getServer();
} catch (IOException exception) { server.getPlayerManager().broadcast(SoulTexts.info(killer.getName().getString() + " claimed " + bountyClaim.reward() + " bounty souls from " + victim.getName().getString() + "."), false);
LOGGER.error("Failed to save Soul Steal data.", exception); }
} trackerCompassService.giveTrackerCompass(killer, victim);
} }
public ConfigBundle bundle() { private void onPlayerDeath(ServerPlayerEntity player, DamageSource damageSource) {
return configBundle; 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);
public SoulStealConfig config() { }
return configBundle.config();
} if (!(damageSource.getAttacker() instanceof ServerPlayerEntity)) {
java.util.List<com.g2806.soulsteal.data.StoredBounty> removedBounties = bountyService.clearForTarget(player.getUuid());
public SoulService soulService() { if (!removedBounties.isEmpty()) {
return soulService; player.sendMessage(SoulTexts.warning("Active bounties on you were cleared because no player claimed them."), false);
} }
}
public PermissionService permissionService() { }
return permissionService;
} private void onServerTick(MinecraftServer server) {
trackerCompassService.tick(server);
public BountyService bountyService() { if (server.getTicks() % 20 != 0) {
return bountyService; return;
} }
public RewardService rewardService() { long now = System.currentTimeMillis();
return rewardService; for (BountyService.ExpiredBountyPayout payout : bountyService.processExpirations(now)) {
} ServerPlayerEntity target = server.getPlayerManager().getPlayer(payout.bounty().targetUuidAsUuid());
if (target != null && payout.reward() > 0L) {
public TrackerCompassService trackerCompassService() { target.sendMessage(SoulTexts.success("You survived a bounty and earned " + payout.reward() + " souls."), false);
return trackerCompassService; }
}
if (payout.reward() > 0L) {
public ShopService shopService() { server.getPlayerManager().broadcast(SoulTexts.info(payout.bounty().targetName() + " survived a bounty and earned " + payout.reward() + " souls."), false);
return shopService; }
} }
public HudService hudService() { hudService.tick(server, now);
return hudService; }
}
} /**
* Reloads the YAML configuration bundle from disk.
*
* @return {@code true} if the reload succeeded; {@code false} if the config file could not be
* loaded
*/
public boolean reloadConfiguration() {
try {
configBundle = ConfigBundle.load(configDirectory);
return true;
} catch (IOException exception) {
LOGGER.error("Failed to reload Soul Steal configuration.", exception);
return false;
}
}
private void saveData() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
private void saveDataQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
LOGGER.error("Failed to save Soul Steal data.", exception);
}
}
private static String blockKey(net.minecraft.world.World world, net.minecraft.util.math.BlockPos pos) {
return world.getRegistryKey().getValue() + "|" + pos;
}
/**
* Returns the block position where a placement will land for a normal face click.
*
* <p>We use the placed block position, not the clicked block position, so player-placed
* contract targets are marked correctly even when the item came from silk touch.</p>
*/
static BlockPos placedBlockPos(BlockPos clickedPos, Direction side) {
return clickedPos.offset(side);
}
/**
* Returns the loaded configuration bundle.
*
* @return the current configuration bundle
*/
public ConfigBundle bundle() {
return configBundle;
}
/**
* Returns the active mod configuration.
*
* @return the current configuration tree
*/
public SoulStealConfig config() {
return configBundle.config();
}
/**
* Returns the service responsible for balance changes.
*
* @return the soul service
*/
public SoulService soulService() {
return soulService;
}
/**
* Returns the permission service.
*
* @return the permission service
*/
public PermissionService permissionService() {
return permissionService;
}
/**
* Returns the bounty service.
*
* @return the bounty service
*/
public BountyService bountyService() {
return bountyService;
}
public ContractService contractService() {
return contractService;
}
public ContractGuiService contractGuiService() {
return contractGuiService;
}
/**
* Returns the reward service.
*
* @return the reward service
*/
public RewardService rewardService() {
return rewardService;
}
/**
* Returns the tracker compass service.
*
* @return the tracker compass service
*/
public TrackerCompassService trackerCompassService() {
return trackerCompassService;
}
/**
* Returns the shop service.
*
* @return the shop service
*/
public ShopService shopService() {
return shopService;
}
/**
* Returns the HUD service.
*
* @return the HUD service
*/
public HudService hudService() {
return hudService;
}
}
@@ -1,7 +1,8 @@
package com.g2806.soulsteal.command; package com.g2806.soulsteal.command;
import com.g2806.soulsteal.SoulStealMod; import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.BountyService; import com.g2806.soulsteal.service.BountyService;
import com.g2806.soulsteal.service.HudService; import com.g2806.soulsteal.service.HudService;
import com.g2806.soulsteal.service.SoulService; 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. */ /** Registers the public command surface for Soul Steal. */
public final class SoulCommandRegistrar { public final class SoulCommandRegistrar {
private SoulCommandRegistrar() { private SoulCommandRegistrar() {
} }
public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) { /**
dispatcher.register(buildRoot("souls", mod)); * Registers the public command roots exposed by the mod.
dispatcher.register(buildRoot("soul", mod)); *
} * @param dispatcher Brigadier dispatcher used by Fabric to install commands
* @param mod active mod instance used to resolve services and configuration
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) { */
return literal(rootName) public static void register(CommandDispatcher<ServerCommandSource> dispatcher, SoulStealMod mod) {
.executes(context -> showOwnBalance(context, mod)) // Register both command roots so players can use either the full name or the short alias.
.then(literal("balance") dispatcher.register(buildRoot("souls", mod));
.executes(context -> showOwnBalance(context, mod)) dispatcher.register(buildRoot("soul", mod));
.then(argument("player", EntityArgumentType.player()) }
.requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(), /**
mod.config().permissions().balanceOthersNode())) * Builds one of the root command aliases and all nested subcommands.
.executes(context -> showTargetBalance(context, mod)))) *
.then(literal("pay") * @param rootName literal command root to register, such as {@code souls} or {@code soul}
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) * @param mod active mod instance used to resolve services and permissions
.then(argument("player", EntityArgumentType.player()) * @return a fully populated command tree for the requested root
.then(argument("amount", LongArgumentType.longArg(1L)) */
.executes(context -> transferSouls(context, mod))))) private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> buildRoot(String rootName, SoulStealMod mod) {
.then(literal("shop") return literal(rootName)
.requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0)) // Running the root command alone shows the player's own balance.
.executes(context -> openShop(context, mod, null)) .executes(context -> showOwnBalance(context, mod))
.then(argument("category", StringArgumentType.word()) // /soul balance
.executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category"))))) .then(literal("balance")
.then(literal("bounty") .executes(context -> showOwnBalance(context, mod))
.requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0)) .then(argument("player", EntityArgumentType.player())
.then(literal("place") // Only privileged sources can inspect another player's balance.
.then(argument("player", EntityArgumentType.player()) .requires(source -> mod.permissionService().hasAny(source, 2,
.then(argument("amount", LongArgumentType.longArg(1L)) mod.config().permissions().adminNode(),
.executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds())) mod.config().permissions().balanceOthersNode()))
.then(argument("durationSeconds", LongArgumentType.longArg(1L)) .executes(context -> showTargetBalance(context, mod))))
.executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds"))))))) // /soul pay <player> <amount>
.then(literal("list") .then(literal("pay")
.executes(context -> listBounties(context, mod, null)) .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.then(argument("player", EntityArgumentType.player()) .then(argument("player", EntityArgumentType.player())
.executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player")))))) .then(argument("amount", LongArgumentType.longArg(1L))
.then(literal("scoreboard") .executes(context -> transferSouls(context, mod)))))
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode())) // /soul shop [category]
.executes(context -> showScoreboardStatus(context, mod)) .then(literal("shop")
.then(literal("toggle") .requires(source -> mod.permissionService().has(source, mod.config().permissions().shopNode(), 0))
.executes(context -> toggleScoreboard(context, mod))) .executes(context -> openShop(context, mod, null))
.then(literal("on") .then(argument("category", StringArgumentType.word())
.executes(context -> setScoreboardVisibility(context, mod, true))) .executes(context -> openShop(context, mod, StringArgumentType.getString(context, "category")))))
.then(literal("off") // /soul bounty ...
.executes(context -> setScoreboardVisibility(context, mod, false)))) .then(literal("bounty")
.then(literal("top") .requires(source -> mod.permissionService().has(source, mod.config().permissions().bountyNode(), 0))
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode())) .then(literal("place")
.executes(context -> showLeaderboard(context, mod, 1)) .then(argument("player", EntityArgumentType.player())
.then(argument("page", IntegerArgumentType.integer(1)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page"))))) // Default duration comes from config unless the caller provides one explicitly.
.then(literal("reload") .executes(context -> placeBounty(context, mod, mod.config().bounty().defaultDurationSeconds()))
.requires(source -> mod.permissionService().hasAny(source, 2, .then(argument("durationSeconds", LongArgumentType.longArg(1L))
mod.config().permissions().adminNode(), .executes(context -> placeBounty(context, mod, LongArgumentType.getLong(context, "durationSeconds")))))))
mod.config().permissions().reloadNode())) .then(literal("list")
.executes(context -> reload(context, mod))) .executes(context -> listBounties(context, mod, null))
.then(literal("set") .then(argument("player", EntityArgumentType.player())
.requires(source -> mod.permissionService().hasAny(source, 2, .executes(context -> listBounties(context, mod, EntityArgumentType.getPlayer(context, "player"))))))
mod.config().permissions().adminNode(), // /soul scoreboard ...
mod.config().permissions().setNode())) .then(literal("scoreboard")
.then(argument("player", EntityArgumentType.player()) .requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().scoreboardNode()))
.then(argument("amount", LongArgumentType.longArg(0L)) .executes(context -> showScoreboardStatus(context, mod))
.executes(context -> setBalance(context, mod))))) // Toggle uses the player's stored preference.
.then(literal("add") .then(literal("toggle")
.requires(source -> mod.permissionService().hasAny(source, 2, .executes(context -> toggleScoreboard(context, mod)))
mod.config().permissions().adminNode(), // Explicit on/off commands are useful for scripts and exact control.
mod.config().permissions().addNode())) .then(literal("on")
.then(argument("player", EntityArgumentType.player()) .executes(context -> setScoreboardVisibility(context, mod, true)))
.then(literal("off")
.executes(context -> setScoreboardVisibility(context, mod, false))))
// /soul top [page]
.then(literal("top")
.requires(source -> mod.permissionService().hasAny(source, 0, mod.config().permissions().leaderboardNode()))
.executes(context -> showLeaderboard(context, mod, 1))
.then(argument("page", IntegerArgumentType.integer(1))
.executes(context -> showLeaderboard(context, mod, IntegerArgumentType.getInteger(context, "page")))))
// /soul tracker give <player> <target>
.then(literal("tracker")
.requires(source -> mod.permissionService().hasAny(source, 2, mod.config().permissions().adminNode()))
.then(literal("give")
.then(argument("player", EntityArgumentType.player())
.then(argument("target", EntityArgumentType.player())
.executes(context -> giveTrackerCompass(context, mod))))))
// /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)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> addBalance(context, mod))))) .executes(context -> addBalance(context, mod)))))
.then(literal("take") .then(literal("take")
.requires(source -> mod.permissionService().hasAny(source, 2, .requires(source -> mod.permissionService().hasAny(source, 2,
mod.config().permissions().adminNode(), mod.config().permissions().adminNode(),
mod.config().permissions().takeNode())) mod.config().permissions().takeNode()))
.then(argument("player", EntityArgumentType.player()) .then(argument("player", EntityArgumentType.player())
.then(argument("amount", LongArgumentType.longArg(1L)) .then(argument("amount", LongArgumentType.longArg(1L))
.executes(context -> takeBalance(context, mod))))); .executes(context -> takeBalance(context, mod)))));
} }
@@ -143,13 +186,14 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException { private static int showScoreboardStatus(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = context.getSource().getPlayerOrThrow(); ServerPlayerEntity player = context.getSource().getPlayerOrThrow();
boolean visible = mod.hudService().isScoreboardVisible(player.getUuid()); boolean visible = mod.hudService().isScoreboardVisible(player.getUuid());
String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled."; String message = visible ? "Your Soul Steal scoreboard is enabled." : "Your Soul Steal scoreboard is disabled.";
if (!mod.config().hud().scoreboard().enabled()) { // This is a player preference; config can still disable the HUD globally.
message += " The server-wide HUD toggle is disabled in config."; if (!mod.config().hud().scoreboard().enabled()) {
} message += " The server-wide HUD toggle is disabled in config.";
}
String finalMessage = message; String finalMessage = message;
context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false); context.getSource().sendFeedback(() -> SoulTexts.info(finalMessage), false);
return 1; return 1;
@@ -179,27 +223,71 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) { private static int showLeaderboard(CommandContext<ServerCommandSource> context, SoulStealMod mod, int page) {
HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page); HudService.LeaderboardPage leaderboardPage = mod.hudService().leaderboard(page);
if (leaderboardPage.entries().isEmpty()) { if (leaderboardPage.entries().isEmpty()) {
context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false); context.getSource().sendFeedback(() -> SoulTexts.info("No tracked soul balances are available yet."), false);
return 1; return 1;
} }
int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize()); int pageSize = Math.max(1, mod.config().hud().leaderboard().pageSize());
context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false); context.getSource().sendFeedback(() -> SoulTexts.info("Soul leaderboard page " + leaderboardPage.page() + "/" + leaderboardPage.totalPages()), false);
for (int index = 0; index < leaderboardPage.entries().size(); index++) { for (int index = 0; index < leaderboardPage.entries().size(); index++) {
HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index); HudService.LeaderboardEntry entry = leaderboardPage.entries().get(index);
int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1; // Convert the page-local index into the stable 1-based rank shown to players.
context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false); int rank = ((leaderboardPage.page() - 1) * pageSize) + index + 1;
} context.getSource().sendFeedback(() -> Text.literal("#" + rank + " " + entry.playerName() + " - " + entry.souls() + " souls").formatted(net.minecraft.util.Formatting.GRAY), false);
return 1; }
} return 1;
}
private static int placeBounty(CommandContext<ServerCommandSource> context, SoulStealMod mod, long durationSeconds) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity placer = context.getSource().getPlayerOrThrow(); /**
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "player"); * Gives a tracker compass to one player that points at another player.
long amount = LongArgumentType.getLong(context, "amount"); *
* @param context command invocation context
* @param mod active mod instance
* @return command result code
* @throws com.mojang.brigadier.exceptions.CommandSyntaxException if either player argument cannot be resolved
*/
private static int giveTrackerCompass(CommandContext<ServerCommandSource> context, SoulStealMod mod) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayerEntity player = EntityArgumentType.getPlayer(context, "player");
ServerPlayerEntity target = EntityArgumentType.getPlayer(context, "target");
mod.trackerCompassService().giveTrackerCompass(player, target);
context.getSource().sendFeedback(() -> SoulTexts.success("Gave a tracker compass to " + player.getName().getString() + " for " + target.getName().getString() + "."), true);
return 1;
}
private static int 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( BountyService.PlaceBountyResult result = mod.bountyService().placeBounty(
placer.getUuid(), placer.getUuid(),
@@ -228,12 +316,13 @@ public final class SoulCommandRegistrar {
return 1; return 1;
} }
context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false); context.getSource().sendFeedback(() -> SoulTexts.info("Active bounties: " + bounties.size()), false);
for (StoredBounty bounty : bounties) { for (StoredBounty bounty : bounties) {
long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L); // Round up so a bounty that expires in a fraction of a second still reports 1 second remaining.
context.getSource().sendFeedback(() -> Text.literal("- " + bounty.targetName() + " | " + bounty.soulValue() + " souls | by " + bounty.placerName() + " | expires in " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(net.minecraft.util.Formatting.GRAY), false); long remainingSeconds = Math.max(0L, (bounty.expiresAtEpochMillis() - System.currentTimeMillis() + 999L) / 1000L);
} context.getSource().sendFeedback(() -> Text.literal("- " + bounty.targetName() + " | " + bounty.soulValue() + " souls | by " + bounty.placerName() + " | expires in " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(net.minecraft.util.Formatting.GRAY), false);
return 1; }
return 1;
} }
private static int reload(CommandContext<ServerCommandSource> context, SoulStealMod mod) { private static int reload(CommandContext<ServerCommandSource> context, SoulStealMod mod) {
@@ -269,4 +358,4 @@ public final class SoulCommandRegistrar {
context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true); context.getSource().sendFeedback(() -> SoulTexts.success("Removed " + amount + " souls from " + target.getName().getString() + ". New balance: " + balance), true);
return 1; return 1;
} }
} }
@@ -1,22 +1,26 @@
package com.g2806.soulsteal.config; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Map; import java.util.Map;
/** Loads and groups the editable YAML files used by the mod. */ /** 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 { public static ConfigBundle load(Path configDirectory) throws IOException {
Files.createDirectories(configDirectory); Files.createDirectories(configDirectory);
Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml()); Map<String, Object> configMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("config.yml"), SoulStealConfig.defaultYaml());
SoulStealConfig config = SoulStealConfig.fromMap(configMap); SoulStealConfig config = SoulStealConfig.fromMap(configMap);
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop(), config.permissions());
return new ConfigBundle(config, shopCatalog); Map<String, Object> contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml());
} ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts());
}
return new ConfigBundle(config, shopCatalog, contractCatalog);
}
}
@@ -9,16 +9,18 @@ public record SoulStealConfig(
EconomyConfig economy, EconomyConfig economy,
BountyConfig bounty, BountyConfig bounty,
TrackerConfig tracker, TrackerConfig tracker,
ContractConfig contracts,
ShopUiConfig shop, ShopUiConfig shop,
HudConfig hud, HudConfig hud,
PermissionConfig permissions PermissionConfig permissions
) { ) {
public static SoulStealConfig fromMap(Map<String, Object> root) { public static SoulStealConfig fromMap(Map<String, Object> root) {
Map<String, Object> economySection = YamlConfigHelper.section(root, "economy"); Map<String, Object> economySection = YamlConfigHelper.section(root, "economy");
Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty"); Map<String, Object> deathPenaltySection = YamlConfigHelper.section(economySection, "death_penalty");
Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer"); Map<String, Object> transferSection = YamlConfigHelper.section(economySection, "transfer");
Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties"); Map<String, Object> bountySection = YamlConfigHelper.section(root, "bounties");
Map<String, Object> trackerSection = YamlConfigHelper.section(root, "tracker"); 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> shopSection = YamlConfigHelper.section(root, "shop");
Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud"); Map<String, Object> hudSection = YamlConfigHelper.section(root, "hud");
Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard"); Map<String, Object> scoreboardSection = YamlConfigHelper.section(hudSection, "scoreboard");
@@ -69,6 +71,16 @@ public record SoulStealConfig(
YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false) YamlConfigHelper.bool(trackerSection, "expire_if_target_offline", false)
); );
ContractConfig contractConfig = new ContractConfig(
YamlConfigHelper.bool(contractsSection, "enabled", true),
YamlConfigHelper.bool(contractsSection, "auto_claim", true),
new ContractHudConfig(
YamlConfigHelper.bool(contractsSection, "hud_enabled", true),
YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract")
),
Math.max(0L, YamlConfigHelper.longValue(contractsSection, "default_repeat_cooldown_seconds", 0L))
);
ShopUiConfig shopUiConfig = new ShopUiConfig( ShopUiConfig shopUiConfig = new ShopUiConfig(
YamlConfigHelper.string(shopSection, "title", "Soul Shop"), YamlConfigHelper.string(shopSection, "title", "Soul Shop"),
clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)), clampRows(YamlConfigHelper.intValue(shopSection, "rows", 3)),
@@ -93,87 +105,98 @@ public record SoulStealConfig(
) )
); );
PermissionConfig permissionConfig = new PermissionConfig( PermissionConfig permissionConfig = new PermissionConfig(
YamlConfigHelper.string(permissionsSection, "admin_node", "soulsteal.admin"), YamlConfigHelper.string(permissionsSection, "admin_node", "soulsteal.admin"),
YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"), YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"),
YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"), YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"),
YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"), YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"),
YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"), YamlConfigHelper.bool(permissionsSection, "luckperms_enabled", true),
YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"), YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"),
YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"), YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"),
YamlConfigHelper.string(permissionsSection, "take_node", "soulsteal.admin.balance.take"), YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"),
YamlConfigHelper.string(permissionsSection, "scoreboard_node", "soulsteal.scoreboard"), YamlConfigHelper.string(permissionsSection, "take_node", "soulsteal.admin.balance.take"),
YamlConfigHelper.string(permissionsSection, "scoreboard_node", "soulsteal.scoreboard"),
YamlConfigHelper.string(permissionsSection, "leaderboard_node", "soulsteal.leaderboard") 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() { public static String defaultYaml() {
return """ return """
economy: economy:
starting_souls: 0 starting_souls: 0
max_souls: 1000000 max_souls: 1000000
kill_reward: 25 kill_reward: 25
death_penalty: death_penalty:
flat: 15 flat: 15
percent: 0.10 percent: 0.10
minimum: 5 minimum: 5
maximum: 100 maximum: 100
transfer: transfer:
enabled: true enabled: true
minimum: 1 minimum: 1
bounties: bounties:
enabled: true enabled: true
min_value: 25 min_value: 25
max_value: 10000 max_value: 10000
default_duration_seconds: 7200 default_duration_seconds: 7200
min_duration_seconds: 600 min_duration_seconds: 600
max_duration_seconds: 86400 max_duration_seconds: 86400
survivor_reward_percent: 0.50 survivor_reward_percent: 0.50
placement_cooldown_seconds: 60 placement_cooldown_seconds: 60
max_active_per_target: 5 max_active_per_target: 5
max_active_per_placer: 3 max_active_per_placer: 3
tracker: tracker:
enabled: true enabled: true
duration_seconds: 900 duration_seconds: 900
update_interval_ticks: 20 update_interval_ticks: 20
expire_if_target_offline: false expire_if_target_offline: false
shop: contracts:
title: "Soul Shop" enabled: true
rows: 3 auto_claim: true
filler_item: "minecraft:black_stained_glass_pane" hud_enabled: true
default_purchase_cooldown_seconds: 0 hud_title: "Active Contract"
enable_custom_amount_selector: true default_repeat_cooldown_seconds: 0
default_max_custom_amount: 64
hud: shop:
scoreboard: title: "Soul Shop"
enabled: true rows: 3
default_visible: false filler_item: "minecraft:light_gray_stained_glass_pane"
title: "Soul HUD" default_purchase_cooldown_seconds: 0
bounty_bossbar: enable_custom_amount_selector: true
enabled: true default_max_custom_amount: 64
title: "Bounty on You"
leaderboard:
page_size: 10
permissions: hud:
# soulsteal.admin grants every admin-only action below. scoreboard:
admin_node: "soulsteal.admin" enabled: true
reload_node: "soulsteal.admin.reload" default_visible: false
shop_node: "soulsteal.shop" title: "Soul HUD"
bounty_node: "soulsteal.bounty"
balance_others_node: "soulsteal.admin.balance.others" bounty_bossbar:
set_node: "soulsteal.admin.balance.set" enabled: true
add_node: "soulsteal.admin.balance.add" title: "Bounty on You"
take_node: "soulsteal.admin.balance.take"
scoreboard_node: "soulsteal.scoreboard" leaderboard:
leaderboard_node: "soulsteal.leaderboard" page_size: 10
""";
permissions:
# soulsteal.admin grants every admin-only action below.
admin_node: "soulsteal.admin"
reload_node: "soulsteal.admin.reload"
shop_node: "soulsteal.shop"
bounty_node: "soulsteal.bounty"
luckperms_enabled: true
balance_others_node: "soulsteal.admin.balance.others"
set_node: "soulsteal.admin.balance.set"
add_node: "soulsteal.admin.balance.add"
take_node: "soulsteal.admin.balance.take"
scoreboard_node: "soulsteal.scoreboard"
leaderboard_node: "soulsteal.leaderboard"
""";
} }
private static double clampPercent(double value) { private static double clampPercent(double value) {
@@ -181,7 +204,7 @@ public record SoulStealConfig(
} }
private static int clampRows(int rows) { private static int clampRows(int rows) {
return Math.max(2, Math.min(6, rows)); return Math.max(2, Math.min(6, rows));
} }
public record EconomyConfig(long startingSouls, long maxSouls, long killReward, DeathPenaltyConfig deathPenalty, TransferConfig transfer) { public record EconomyConfig(long startingSouls, long maxSouls, long killReward, DeathPenaltyConfig deathPenalty, TransferConfig transfer) {
@@ -219,6 +242,12 @@ public record SoulStealConfig(
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) { public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
} }
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud, long defaultRepeatCooldownSeconds) {
}
public record ContractHudConfig(boolean enabled, String title) {
}
public record ShopUiConfig( public record ShopUiConfig(
String title, String title,
int rows, int rows,
@@ -241,17 +270,18 @@ public record SoulStealConfig(
public record LeaderboardConfig(int pageSize) { public record LeaderboardConfig(int pageSize) {
} }
public record PermissionConfig( public record PermissionConfig(
String adminNode, String adminNode,
String reloadNode, String reloadNode,
String shopNode, String shopNode,
String bountyNode, String bountyNode,
String balanceOthersNode, boolean luckpermsEnabled,
String setNode, String balanceOthersNode,
String addNode, String setNode,
String takeNode, String addNode,
String takeNode,
String scoreboardNode, String scoreboardNode,
String leaderboardNode String leaderboardNode
) { ) {
} }
} }
@@ -0,0 +1,139 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig.ContractConfig;
import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Parsed contract catalog loaded from `catalog.yml`.
*
* <p>Contracts are grouped by type in the GUI, but remain flat in the config so each entry can be
* addressed by its own id.</p>
*/
public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEnabled, String hudTitle, List<ContractDefinition> contracts) {
public static ContractCatalog fromMap(Map<String, Object> root, ContractConfig config) {
List<ContractDefinition> contracts = new ArrayList<>();
Object rawContracts = root.get("contracts");
if (rawContracts instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract, config);
}
} else if (rawContracts instanceof Map<?, ?> rawSections) {
for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) {
if (sectionEntry.getValue() instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract, config);
}
}
}
}
return new ContractCatalog(config.enabled(), config.autoClaim(), config.hud().enabled(), config.hud().title(), contracts);
}
public Optional<ContractDefinition> contract(String id) {
return contracts.stream().filter(contract -> contract.id().equalsIgnoreCase(id)).findFirst();
}
public List<ContractDefinition> contractsOfType(ContractType type) {
return contracts.stream().filter(contract -> contract.type() == type).toList();
}
public static String defaultYaml() {
return """
contracts:
mining:
- id: "iron_miner"
name: "Mining Contracts"
icon: "minecraft:iron_pickaxe"
type: "mining"
targets:
- "minecraft:iron_ore"
- "minecraft:deepslate_iron_ore"
target_name: "Iron Ore"
amount: 20
reward: 200
repeatable: true
cooldown: 10
matches:
- "Iron Ore"
- "Deepslate Iron Ore"
hunting:
- id: "zombie_hunter"
name: "Zombie Hunter"
icon: "minecraft:zombie_head"
type: "hunting"
target: "minecraft:zombie"
target_name: "Zombie"
description: "Hunt zombies to earn souls."
amount: 20
reward: 200
repeatable: true
""";
}
private static Map<String, Object> toStringMap(Map<?, ?> rawMap) {
Map<String, Object> converted = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() != null) {
converted.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return converted;
}
private static void addContract(List<ContractDefinition> contracts, Object rawContract, ContractConfig config) {
if (!(rawContract instanceof Map<?, ?> rawMap)) {
return;
}
Map<String, Object> map = toStringMap(rawMap);
String id = YamlConfigHelper.string(map, "id", "").trim();
if (id.isBlank()) {
return;
}
String typeName = YamlConfigHelper.string(map, "type", "mining").trim().toUpperCase();
ContractType type;
try {
type = ContractType.valueOf(typeName);
} catch (IllegalArgumentException ignored) {
return;
}
java.util.List<String> targets = YamlConfigHelper.stringList(map, "targets");
if (targets.isEmpty()) {
// Backward compatibility: allow the old single-target field for existing catalogs.
String single = YamlConfigHelper.string(map, "target", "").trim();
if (!single.isBlank()) {
targets = java.util.List.of(single);
}
}
targets = targets.stream().map(String::trim).filter(target -> !target.isBlank()).distinct().toList();
java.util.List<String> matches = YamlConfigHelper.stringList(map, "matches")
.stream()
.map(String::trim)
.filter(match -> !match.isBlank())
.distinct()
.toList();
long cooldown = Math.max(0L, YamlConfigHelper.longValue(map, "cooldown", YamlConfigHelper.longValue(map, "cooldown_seconds", config.defaultRepeatCooldownSeconds())));
contracts.add(new ContractDefinition(
id,
YamlConfigHelper.string(map, "name", id),
YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"),
type,
targets,
matches,
YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)),
YamlConfigHelper.string(map, "description", ""),
Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)),
Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)),
YamlConfigHelper.bool(map, "repeatable", true),
cooldown
));
}
}
@@ -0,0 +1,46 @@
package com.g2806.soulsteal.contract;
import java.util.List;
import java.util.stream.Collectors;
/**
* Immutable definition for one contract entry loaded from `catalog.yml`.
*
* <p>The YAML key is the internal id used for selection and persistence, while {@code name} is
* the player-facing label shown in the GUI and HUD.</p>
*/
public record ContractDefinition(
String id,
String name,
String iconItemId,
ContractType type,
List<String> targetIds,
List<String> displayMatches,
String targetName,
String description,
long amountRequired,
long reward,
boolean repeatable,
long cooldownSeconds
) {
public String primaryTarget() {
return targetIds == null || targetIds.isEmpty() ? "" : targetIds.get(0);
}
public String targetSummary() {
if (targetIds == null || targetIds.isEmpty()) {
return targetName;
}
if (targetIds.size() == 1) {
return targetName;
}
return targetName + " (" + targetIds.size() + " targets)";
}
public String displayMatchesSummary() {
if (displayMatches == null || displayMatches.isEmpty()) {
return "";
}
return displayMatches.stream().collect(Collectors.joining(", "));
}
}
@@ -0,0 +1,303 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.ConfigBundle;
import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.service.RewardService;
import com.g2806.soulsteal.service.SoulService;
import com.g2806.soulsteal.util.DurationFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/**
* Builds the server-side contract browser and dispatches click handling for contract selection.
*
* <p>The contract UI follows the same chest-based presentation style as the shop GUI so players
* can scan categories, read contract details, and select an active objective without client-side
* mods.</p>
*/
public final class ContractGuiService {
private static final int PAGE_ROWS = 6;
private static final int ITEM_SLOT_COUNT = 45;
private static final int SLOT_HOME = 45;
private static final int SLOT_PREVIOUS = 46;
private static final int SLOT_INFO = 49;
private static final int SLOT_CLEAR = 52;
private static final int SLOT_NEXT = 53;
private final Supplier<ConfigBundle> bundleSupplier;
private final ContractService contractService;
private final RewardService rewardService;
private final SoulService soulService;
public ContractGuiService(Supplier<ConfigBundle> bundleSupplier, ContractService contractService, RewardService rewardService, SoulService soulService) {
this.bundleSupplier = bundleSupplier;
this.contractService = contractService;
this.rewardService = rewardService;
this.soulService = soulService;
}
public void openContracts(ServerPlayerEntity player) {
openHome(player, 0);
}
public void openHome(ServerPlayerEntity player, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new HomeView(page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public void openCategory(ServerPlayerEntity player, String categoryKey, int page) {
player.openHandledScreen(new net.minecraft.screen.SimpleNamedScreenHandlerFactory(
(syncId, inventory, ignored) -> new ContractScreenHandler(syncId, inventory, this, new CategoryView(categoryKey, page)),
Text.literal(bundleSupplier.get().contractCatalog().hudTitle())
));
}
public SimpleInventory createInventory(ServerPlayerEntity player, View view) {
return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView);
};
}
public void handleClick(ServerPlayerEntity player, View view, int slotIndex) {
if (view instanceof HomeView homeView) {
handleHomeClick(player, homeView, slotIndex);
} else if (view instanceof CategoryView categoryView) {
handleCategoryClick(player, categoryView, slotIndex);
}
}
public ContractDefinition selected(ServerPlayerEntity player) {
return contractService.selectedContract(player.getUuid()).orElse(null);
}
public long progress(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return selected == null ? 0L : contractService.progress(player.getUuid(), selected.id());
}
public long progress(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.progress(player.getUuid(), contract.id());
}
public boolean select(ServerPlayerEntity player, ContractDefinition contract) {
return contractService.selectContract(player, contract.id());
}
public void clearSelection(ServerPlayerEntity player) {
contractService.clearContract(player.getUuid());
}
private SimpleInventory createHomeInventory(ServerPlayerEntity player, HomeView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> mining = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
List<ContractDefinition> hunting = bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
inventory.setStack(0, createCategoryButton("Mining Contracts", "minecraft:iron_pickaxe", mining.size(), "Browse mining contracts"));
inventory.setStack(1, createCategoryButton("Hunting Contracts", "minecraft:zombie_head", hunting.size(), "Browse mob hunting contracts"));
inventory.setStack(SLOT_INFO, createHomeInfoButton(player));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
return inventory;
}
private SimpleInventory createCategoryInventory(ServerPlayerEntity player, CategoryView view) {
SimpleInventory inventory = filledInventory(PAGE_ROWS);
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
for (int index = 0; index < contracts.size() && index < ITEM_SLOT_COUNT; index++) {
inventory.setStack(index, createContractStack(player, contracts.get(index)));
}
inventory.setStack(SLOT_HOME, createBackButton());
inventory.setStack(SLOT_PREVIOUS, createPageButton(view.page(), totalPages(player, view.categoryKey()), true));
inventory.setStack(SLOT_INFO, createCategoryInfoButton(player, view.categoryKey()));
inventory.setStack(SLOT_CLEAR, createClearButton(player));
inventory.setStack(SLOT_NEXT, createPageButton(view.page(), totalPages(player, view.categoryKey()), false));
return inventory;
}
private void handleHomeClick(ServerPlayerEntity player, HomeView view, int slotIndex) {
if (slotIndex == 0) {
openCategory(player, "mining", 0);
} else if (slotIndex == 1) {
openCategory(player, "hunting", 0);
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openHome(player, view.page());
}
}
private void handleCategoryClick(ServerPlayerEntity player, CategoryView view, int slotIndex) {
if (slotIndex < ITEM_SLOT_COUNT) {
List<ContractDefinition> contracts = pagedContracts(contractsFor(player, view.categoryKey()), view.page());
if (slotIndex >= contracts.size()) {
return;
}
ContractDefinition contract = contracts.get(slotIndex);
if (select(player, contract)) {
player.sendMessage(net.minecraft.text.Text.literal("Selected contract: " + contract.name()).formatted(Formatting.GREEN), false);
}
openCategory(player, view.categoryKey(), view.page());
return;
}
if (slotIndex == SLOT_HOME) {
openContracts(player);
} else if (slotIndex == SLOT_PREVIOUS) {
openCategory(player, view.categoryKey(), Math.max(0, view.page() - 1));
} else if (slotIndex == SLOT_NEXT) {
openCategory(player, view.categoryKey(), Math.min(totalPages(player, view.categoryKey()) - 1, view.page() + 1));
} else if (slotIndex == SLOT_CLEAR) {
clearSelection(player);
player.sendMessage(net.minecraft.text.Text.literal("Cleared selected contract.").formatted(Formatting.GREEN), false);
openCategory(player, view.categoryKey(), view.page());
}
}
private ItemStack createContractStack(ServerPlayerEntity player, ContractDefinition contract) {
long progress = progress(player, contract);
List<Text> lore = new ArrayList<>();
if (!contract.description().isBlank()) {
lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY));
}
lore.add(Text.literal("Target: " + contract.targetSummary()).formatted(Formatting.AQUA));
if (contract.displayMatches() != null && !contract.displayMatches().isEmpty()) {
lore.add(Text.literal("Matches: " + contract.displayMatchesSummary()).formatted(Formatting.DARK_AQUA));
}
if (contract.cooldownSeconds() > 0L) {
lore.add(Text.literal("Cooldown: " + DurationFormatter.formatSeconds(contract.cooldownSeconds())).formatted(Formatting.DARK_GRAY));
}
lore.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GOLD));
lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
ContractDefinition selected = selected(player);
if (selected != null && selected.id().equalsIgnoreCase(contract.id())) {
lore.add(Text.literal("Selected").formatted(Formatting.AQUA));
}
return createPreviewStack(contract.iconItemId(), contract.name(), lore);
}
private ItemStack createCategoryButton(String name, String iconItemId, int count, String description) {
return createPreviewStack(iconItemId, name, List.of(
Text.literal(description).formatted(Formatting.GRAY),
Text.literal("Contracts: " + count).formatted(Formatting.AQUA)
));
}
private ItemStack createHomeInfoButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
List<Text> lore = new ArrayList<>();
lore.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD));
lore.add(Text.literal("Selected: " + (selected == null ? "None" : selected.name())).formatted(Formatting.AQUA));
if (selected != null) {
lore.add(Text.literal("Progress: " + progress(player, selected) + "/" + selected.amountRequired()).formatted(Formatting.GRAY));
}
return createPreviewStack("minecraft:nether_star", "Contract Browser", lore);
}
private ItemStack createCategoryInfoButton(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = contractsFor(player, categoryKey);
return createPreviewStack("minecraft:nether_star", categoryLabel(categoryKey), List.of(
Text.literal("Contracts: " + contracts.size()).formatted(Formatting.AQUA),
Text.literal("Selected: " + (selected(player) == null ? "None" : selected(player).name())).formatted(Formatting.GOLD)
));
}
private ItemStack createBackButton() {
return createPreviewStack("minecraft:barrier", "Back", List.of(Text.literal("Return to the contract browser.").formatted(Formatting.GRAY)));
}
private ItemStack createClearButton(ServerPlayerEntity player) {
ContractDefinition selected = selected(player);
return createPreviewStack("minecraft:redstone_torch", "Clear Selected", List.of(
Text.literal(selected == null ? "No contract selected." : "Clear: " + selected.name()).formatted(Formatting.GRAY),
Text.literal("Remove your active contract.").formatted(Formatting.DARK_GRAY)
));
}
private ItemStack createPageButton(int page, int totalPages, boolean previous) {
boolean available = previous ? page > 0 : page < totalPages - 1;
String label = previous ? "Previous Page" : "Next Page";
return createPreviewStack("minecraft:arrow", label, List.of(
Text.literal("Page " + (page + 1) + " of " + totalPages).formatted(Formatting.GRAY),
Text.literal(available ? "Click to switch pages." : "No more pages in this direction.").formatted(available ? Formatting.AQUA : Formatting.DARK_GRAY)
));
}
private List<ContractDefinition> contractsFor(ServerPlayerEntity player, String categoryKey) {
List<ContractDefinition> contracts = switch (categoryKey) {
case "mining" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.MINING);
case "hunting" -> bundleSupplier.get().contractCatalog().contractsOfType(ContractType.HUNTING);
default -> List.of();
};
return contracts.stream()
.filter(contract -> contractService.isContractAvailable(player.getUuid(), contract))
.toList();
}
private List<ContractDefinition> pagedContracts(List<ContractDefinition> contracts, int page) {
int perPage = Math.max(1, ITEM_SLOT_COUNT);
int totalPages = Math.max(1, (int) Math.ceil(contracts.size() / (double) perPage));
int actualPage = Math.max(0, Math.min(totalPages - 1, page));
int from = actualPage * perPage;
int to = Math.min(contracts.size(), from + perPage);
return contracts.subList(from, to);
}
private int totalPages(ServerPlayerEntity player, String categoryKey) {
return Math.max(1, (int) Math.ceil(contractsFor(player, categoryKey).size() / (double) ITEM_SLOT_COUNT));
}
private String categoryLabel(String categoryKey) {
return switch (categoryKey) {
case "mining" -> "Mining Contracts";
case "hunting" -> "Hunting Contracts";
default -> "Contracts";
};
}
private SimpleInventory filledInventory(int rows) {
SimpleInventory inventory = new SimpleInventory(rows * 9);
ItemStack filler = createPreviewStack(bundleSupplier.get().config().shop().fillerItemId(), " ", List.of());
filler.remove(DataComponentTypes.LORE);
for (int slot = 0; slot < inventory.size(); slot++) {
inventory.setStack(slot, filler.copy());
}
return inventory;
}
private ItemStack createPreviewStack(String itemId, String name, List<Text> lore) {
ItemStack stack = rewardService.createPreviewStack(itemId, name, lore);
return stack;
}
private record HomeView(int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
private record CategoryView(String categoryKey, int page) implements View {
@Override
public int rows() {
return PAGE_ROWS;
}
}
public sealed interface View permits HomeView, CategoryView {
int rows();
}
}
@@ -0,0 +1,64 @@
package com.g2806.soulsteal.contract;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.screen.ScreenHandler;
import net.minecraft.screen.ScreenHandlerType;
import net.minecraft.screen.slot.Slot;
import net.minecraft.screen.slot.SlotActionType;
import net.minecraft.server.network.ServerPlayerEntity;
/**
* Generic container handler that turns contract GUI clicks into selection actions.
*/
public final class ContractScreenHandler extends ScreenHandler {
private final SimpleInventory inventory;
private final ContractGuiService guiService;
private final ContractGuiService.View view;
public ContractScreenHandler(int syncId, PlayerInventory playerInventory, ContractGuiService guiService, ContractGuiService.View view) {
super(view.rows() == 3 ? ScreenHandlerType.GENERIC_9X3 : ScreenHandlerType.GENERIC_9X6, syncId);
this.guiService = guiService;
this.view = view;
this.inventory = guiService.createInventory((ServerPlayerEntity) playerInventory.player, view);
int rows = view.rows();
for (int row = 0; row < rows; row++) {
for (int column = 0; column < 9; column++) {
int slotIndex = row * 9 + column;
this.addSlot(new Slot(inventory, slotIndex, 8 + column * 18, 18 + row * 18));
}
}
int playerInventoryY = 18 + rows * 18 + 14;
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column + row * 9 + 9, 8 + column * 18, playerInventoryY + row * 18));
}
}
int hotbarY = playerInventoryY + 58;
for (int column = 0; column < 9; column++) {
this.addSlot(new Slot(playerInventory, column, 8 + column * 18, hotbarY));
}
}
@Override
public ItemStack quickMove(PlayerEntity player, int slot) {
return ItemStack.EMPTY;
}
@Override
public boolean canUse(PlayerEntity player) {
return true;
}
@Override
public void onSlotClick(int slotIndex, int button, SlotActionType actionType, PlayerEntity player) {
if (player instanceof ServerPlayerEntity serverPlayer && slotIndex >= 0 && slotIndex < inventory.size()) {
guiService.handleClick(serverPlayer, view, slotIndex);
}
}
}
@@ -0,0 +1,6 @@
package com.g2806.soulsteal.contract;
public enum ContractType {
MINING,
HUNTING
}
@@ -18,12 +18,18 @@ public final class SoulStealData {
private List<StoredBounty> activeBounties = new ArrayList<>(); private List<StoredBounty> activeBounties = new ArrayList<>();
private Map<String, Set<String>> unlockedEntries = new HashMap<>(); private Map<String, Set<String>> unlockedEntries = new HashMap<>();
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>(); private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>(); private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
private Map<String, Integer> grantedRankPriorities = new HashMap<>();
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>(); private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>(); private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>(); private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, String> selectedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractProgress = new HashMap<>();
private Map<String, Set<String>> completedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractCooldowns = new HashMap<>();
private Set<String> playerPlacedMiningTargets = new HashSet<>();
public SoulStealData normalize() { public SoulStealData normalize() {
if (souls == null) { if (souls == null) {
souls = new HashMap<>(); souls = new HashMap<>();
} }
@@ -36,50 +42,132 @@ public final class SoulStealData {
if (purchaseCooldowns == null) { if (purchaseCooldowns == null) {
purchaseCooldowns = new HashMap<>(); purchaseCooldowns = new HashMap<>();
} }
if (grantedPermissions == null) { if (grantedPermissions == null) {
grantedPermissions = new HashMap<>(); grantedPermissions = new HashMap<>();
} }
if (grantedRankPriorities == null) {
grantedRankPriorities = new HashMap<>();
}
if (bountyPlacementCooldowns == null) { if (bountyPlacementCooldowns == null) {
bountyPlacementCooldowns = new HashMap<>(); bountyPlacementCooldowns = new HashMap<>();
} }
if (playerNames == null) { if (playerNames == null) {
playerNames = new HashMap<>(); playerNames = new HashMap<>();
} }
if (scoreboardVisibility == null) { if (scoreboardVisibility == null) {
scoreboardVisibility = new HashMap<>(); scoreboardVisibility = new HashMap<>();
}
if (selectedContracts == null) {
selectedContracts = new HashMap<>();
}
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
if (completedContracts == null) {
completedContracts = new HashMap<>();
} }
return this; if (contractCooldowns == null) {
} contractCooldowns = new HashMap<>();
}
public Map<String, Long> souls() { if (playerPlacedMiningTargets == null) {
playerPlacedMiningTargets = new HashSet<>();
}
return this;
}
/**
* Returns the persistent soul balance table keyed by player UUID string.
*
* @return mutable soul balance map
*/
public Map<String, Long> souls() {
return souls; return souls;
} }
public List<StoredBounty> activeBounties() { /**
* Returns the active bounty list.
*
* @return mutable list of active bounties
*/
public List<StoredBounty> activeBounties() {
return activeBounties; return activeBounties;
} }
public Map<String, Set<String>> unlockedEntries() { /**
* Returns the set of shop entries unlocked per player.
*
* @return mutable unlock table
*/
public Map<String, Set<String>> unlockedEntries() {
return unlockedEntries; return unlockedEntries;
} }
public Map<String, Map<String, Long>> purchaseCooldowns() { /**
* Returns the per-entry purchase cooldown table.
*
* @return mutable cooldown map
*/
public Map<String, Map<String, Long>> purchaseCooldowns() {
return purchaseCooldowns; return purchaseCooldowns;
} }
public Map<String, Map<String, Boolean>> grantedPermissions() { /**
return grantedPermissions; * Returns the internal permission fallback table.
} *
* @return mutable permission map
public Map<String, Long> bountyPlacementCooldowns() { */
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
}
public Map<String, Integer> grantedRankPriorities() {
return grantedRankPriorities;
}
/**
* Returns the bounty placement cooldown table.
*
* @return mutable placement cooldown map
*/
public Map<String, Long> bountyPlacementCooldowns() {
return bountyPlacementCooldowns; return bountyPlacementCooldowns;
} }
public Map<String, String> playerNames() { /**
* Returns the last known player names table.
*
* @return mutable player-name map
*/
public Map<String, String> playerNames() {
return playerNames; return playerNames;
}
/**
* Returns the per-player scoreboard visibility table.
*
* @return mutable scoreboard visibility map
*/
public Map<String, Boolean> scoreboardVisibility() {
return scoreboardVisibility;
}
public Map<String, String> selectedContracts() {
return selectedContracts;
}
public Map<String, Map<String, Long>> contractProgress() {
return contractProgress;
}
public Map<String, Set<String>> completedContracts() {
return completedContracts;
} }
public Map<String, Boolean> scoreboardVisibility() { public Map<String, Map<String, Long>> contractCooldowns() {
return scoreboardVisibility; return contractCooldowns;
} }
}
public Set<String> playerPlacedMiningTargets() {
return playerPlacedMiningTargets;
}
}
@@ -24,12 +24,17 @@ public final class SoulStealDataStore {
private final Path dataFile; private final Path dataFile;
private SoulStealData data = new SoulStealData(); private SoulStealData data = new SoulStealData();
public SoulStealDataStore(Path dataDirectory) { public SoulStealDataStore(Path dataDirectory) {
this.dataDirectory = dataDirectory; this.dataDirectory = dataDirectory;
this.dataFile = dataDirectory.resolve("soulsteal-data.json"); this.dataFile = dataDirectory.resolve("soulsteal-data.json");
} }
public synchronized void load() throws IOException { /**
* Loads persistent state from disk, creating a new file if needed.
*
* @throws IOException if the file cannot be read or created
*/
public synchronized void load() throws IOException {
Files.createDirectories(dataDirectory); Files.createDirectories(dataDirectory);
if (Files.notExists(dataFile)) { if (Files.notExists(dataFile)) {
data = new SoulStealData(); data = new SoulStealData();
@@ -41,13 +46,23 @@ public final class SoulStealDataStore {
SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class); SoulStealData loaded = GSON.fromJson(reader, SoulStealData.class);
data = loaded == null ? new SoulStealData() : loaded.normalize(); data = loaded == null ? new SoulStealData() : loaded.normalize();
} }
} }
public synchronized SoulStealData data() { /**
* Returns the in-memory persistent data snapshot.
*
* @return mutable data model used by the running server
*/
public synchronized SoulStealData data() {
return data; return data;
} }
public synchronized void save() throws IOException { /**
* Saves the current in-memory state to disk atomically when possible.
*
* @throws IOException if the file cannot be written
*/
public synchronized void save() throws IOException {
Files.createDirectories(dataDirectory); Files.createDirectories(dataDirectory);
Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp"); Path tempFile = dataFile.resolveSibling(dataFile.getFileName() + ".tmp");
try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
@@ -60,4 +75,4 @@ public final class SoulStealDataStore {
Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING); Files.move(tempFile, dataFile, StandardCopyOption.REPLACE_EXISTING);
} }
} }
} }
@@ -17,16 +17,31 @@ public record StoredBounty(
long soulValue, long soulValue,
long createdAtEpochMillis, long createdAtEpochMillis,
long expiresAtEpochMillis long expiresAtEpochMillis
) { ) {
public UUID idAsUuid() { /**
* Parses the bounty id as a UUID.
*
* @return bounty UUID
*/
public UUID idAsUuid() {
return UUID.fromString(id); return UUID.fromString(id);
} }
public UUID placerUuidAsUuid() { /**
* Parses the placer id as a UUID.
*
* @return placer UUID
*/
public UUID placerUuidAsUuid() {
return UUID.fromString(placerUuid); return UUID.fromString(placerUuid);
} }
public UUID targetUuidAsUuid() { /**
* Parses the target id as a UUID.
*
* @return target UUID
*/
public UUID targetUuidAsUuid() {
return UUID.fromString(targetUuid); return UUID.fromString(targetUuid);
} }
} }
@@ -18,13 +18,25 @@ public final class BountyService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final SoulService soulService; private final SoulService soulService;
public BountyService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore, SoulService soulService) { public BountyService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
this.soulService = soulService; this.soulService = soulService;
} }
public PlaceBountyResult placeBounty( /**
* Attempts to place a bounty on a target player.
*
* @param placerUuid player paying for the bounty
* @param placerName display name used for messages
* @param targetUuid target player UUID
* @param targetName target display name used for messages
* @param amount bounty value in souls
* @param durationSeconds bounty lifetime in seconds
* @param nowEpochMillis current time used for cooldown and expiry calculations
* @return placement outcome and the created bounty when successful
*/
public PlaceBountyResult placeBounty(
UUID placerUuid, UUID placerUuid,
String placerName, String placerName,
UUID targetUuid, UUID targetUuid,
@@ -84,9 +96,16 @@ public final class BountyService {
data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L)); data.bountyPlacementCooldowns().put(placerKey, nowEpochMillis + (bountyConfig.placementCooldownSeconds() * 1000L));
saveQuietly(); saveQuietly();
return new PlaceBountyResult(true, "Bounty placed successfully.", bounty); return new PlaceBountyResult(true, "Bounty placed successfully.", bounty);
} }
public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) { /**
* Claims all active bounties on a target after a successful kill.
*
* @param killerUuid player receiving the payout
* @param targetUuid target player whose bounties may be claimed
* @return the combined payout and the list of claimed bounties
*/
public ClaimBountyResult claimForKill(UUID killerUuid, UUID targetUuid) {
List<StoredBounty> claimed = new ArrayList<>(); List<StoredBounty> claimed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
long reward = 0L; long reward = 0L;
@@ -108,9 +127,15 @@ public final class BountyService {
} }
return new ClaimBountyResult(reward, claimed); return new ClaimBountyResult(reward, claimed);
} }
public List<StoredBounty> clearForTarget(UUID targetUuid) { /**
* Clears all active bounties on a target without paying them out.
*
* @param targetUuid target player UUID
* @return the removed bounty records
*/
public List<StoredBounty> clearForTarget(UUID targetUuid) {
List<StoredBounty> removed = new ArrayList<>(); List<StoredBounty> removed = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
String targetKey = key(targetUuid); String targetKey = key(targetUuid);
@@ -130,9 +155,15 @@ public final class BountyService {
} }
return removed; return removed;
} }
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) { /**
* Processes expired bounties and pays the configured survivor reward where applicable.
*
* @param nowEpochMillis current time used to determine expiration
* @return payout records for every expired bounty handled in this pass
*/
public List<ExpiredBountyPayout> processExpirations(long nowEpochMillis) {
SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty(); SoulStealConfig.BountyConfig bountyConfig = configSupplier.get().bounty();
List<ExpiredBountyPayout> payouts = new ArrayList<>(); List<ExpiredBountyPayout> payouts = new ArrayList<>();
Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator(); Iterator<StoredBounty> iterator = dataStore.data().activeBounties().iterator();
@@ -155,18 +186,35 @@ public final class BountyService {
saveQuietly(); saveQuietly();
} }
return payouts; return payouts;
} }
public List<StoredBounty> activeBounties() { /**
* Returns a snapshot of all active bounties.
*
* @return immutable copy of the current active bounty list
*/
public List<StoredBounty> activeBounties() {
return List.copyOf(dataStore.data().activeBounties()); return List.copyOf(dataStore.data().activeBounties());
} }
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) { /**
* Returns all active bounties for a specific target player.
*
* @param targetUuid target player UUID
* @return bounties currently assigned to that player
*/
public List<StoredBounty> activeBountiesForTarget(UUID targetUuid) {
String targetKey = key(targetUuid); String targetKey = key(targetUuid);
return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList(); return dataStore.data().activeBounties().stream().filter(bounty -> bounty.targetUuid().equals(targetKey)).toList();
} }
public long nextPlacementTime(UUID placerUuid) { /**
* Returns the next time the given placer may create another bounty.
*
* @param placerUuid player UUID to inspect
* @return epoch milliseconds when the placement cooldown ends, or {@code 0} if none exists
*/
public long nextPlacementTime(UUID placerUuid) {
return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L); return dataStore.data().bountyPlacementCooldowns().getOrDefault(key(placerUuid), 0L);
} }
@@ -193,4 +241,4 @@ public final class BountyService {
public record ExpiredBountyPayout(StoredBounty bounty, long reward) { public record ExpiredBountyPayout(StoredBounty bounty, long reward) {
} }
} }
@@ -0,0 +1,169 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.contract.ContractCatalog;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.registry.Registries;
public final class ContractService {
private final Supplier<ContractCatalog> catalogSupplier;
private final SoulStealDataStore dataStore;
private final SoulService soulService;
public ContractService(Supplier<ContractCatalog> catalogSupplier, SoulStealDataStore dataStore, SoulService soulService) {
this.catalogSupplier = catalogSupplier;
this.dataStore = dataStore;
this.soulService = soulService;
}
public Optional<ContractDefinition> selectedContract(UUID playerUuid) {
String contractId = dataStore.data().selectedContracts().get(key(playerUuid));
if (contractId == null) {
return Optional.empty();
}
return catalogSupplier.get().contract(contractId);
}
public boolean selectContract(ServerPlayerEntity player, String contractId) {
Optional<ContractDefinition> contract = catalogSupplier.get().contract(contractId);
if (contract.isEmpty()) {
return false;
}
if (!isContractAvailable(player.getUuid(), contract.get())) {
return false;
}
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
dataStore.data().contractProgress().remove(key(player.getUuid()));
saveQuietly();
return true;
}
public void clearContract(UUID playerUuid) {
dataStore.data().selectedContracts().remove(key(playerUuid));
dataStore.data().contractProgress().remove(key(playerUuid));
saveQuietly();
}
public long progress(UUID playerUuid) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.values().stream().mapToLong(Long::longValue).sum();
}
public long progress(UUID playerUuid, String contractId) {
return dataStore.data().contractProgress()
.getOrDefault(key(playerUuid), Map.of())
.getOrDefault(contractId, 0L);
}
public boolean hasCompletedContract(UUID playerUuid, String contractId) {
return dataStore.data().completedContracts()
.getOrDefault(key(playerUuid), java.util.Set.of())
.contains(contractId);
}
public boolean isContractAvailable(UUID playerUuid, ContractDefinition contract) {
String playerKey = key(playerUuid);
if (!contract.repeatable() && hasCompletedContract(playerUuid, contract.id())) {
return false;
}
// Repeatable contracts re-enter the browser only after their per-player cooldown expires.
if (contract.repeatable() && contract.cooldownSeconds() > 0L) {
long until = dataStore.data().contractCooldowns()
.getOrDefault(playerKey, Map.of())
.getOrDefault(contract.id(), 0L);
return System.currentTimeMillis() >= until;
}
return true;
}
public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId);
}
public void recordHunting(ServerPlayerEntity player, String entityId) {
record(player, ContractType.HUNTING, entityId);
}
public boolean matchesMiningTarget(net.minecraft.block.Block block) {
if (!catalogSupplier.get().enabled()) {
return false;
}
String blockId = Registries.BLOCK.getId(block).toString();
// Used when a player places a block that is also a mining target.
// We record that position so breaking the placed block does not count as real mining.
return catalogSupplier.get().contractsOfType(ContractType.MINING).stream()
.map(ContractDefinition::targetIds)
.filter(targetIds -> targetIds != null)
.flatMap(List::stream)
.anyMatch(target -> target.equalsIgnoreCase(blockId));
}
private void record(ServerPlayerEntity player, ContractType type, String targetId) {
Optional<ContractDefinition> selected = selectedContract(player.getUuid());
if (selected.isEmpty() || !catalogSupplier.get().enabled()) {
return;
}
ContractDefinition contract = selected.get();
if (contract.type() != type) {
return;
}
boolean matches = contract.targetIds() != null && contract.targetIds().stream().anyMatch(t -> t.equalsIgnoreCase(targetId));
if (!matches) {
return;
}
String playerKey = key(player.getUuid());
Map<String, Long> progressMap = dataStore.data().contractProgress().computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>());
long updated = progressMap.getOrDefault(contract.id(), 0L) + 1L;
if (updated >= contract.amountRequired()) {
progressMap.remove(contract.id());
soulService.addSouls(player.getUuid(), contract.reward());
player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+"
+ contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false);
if (!contract.repeatable()) {
// One-time contracts are permanently removed after completion.
dataStore.data().selectedContracts().remove(playerKey);
dataStore.data().completedContracts()
.computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>())
.add(contract.id());
} else {
if (contract.cooldownSeconds() > 0L) {
// Repeatable contracts with cooldown reappear after the timer expires.
dataStore.data().selectedContracts().remove(playerKey);
dataStore.data().contractCooldowns()
.computeIfAbsent(playerKey, ignored -> new java.util.HashMap<>())
.put(contract.id(), System.currentTimeMillis() + (contract.cooldownSeconds() * 1000L));
} else {
// Repeatable contracts with no cooldown immediately restart.
progressMap.put(contract.id(), 0L);
}
}
} else {
progressMap.put(contract.id(), updated);
}
saveQuietly();
}
private void saveQuietly() {
try {
dataStore.save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist contract data.", exception);
}
}
private static String key(UUID playerUuid) {
return playerUuid.toString();
}
}
@@ -1,9 +1,11 @@
package com.g2806.soulsteal.service; package com.g2806.soulsteal.service;
import com.g2806.soulsteal.config.SoulStealConfig; import com.g2806.soulsteal.config.SoulStealConfig;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.util.DurationFormatter; import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter;
import com.g2806.soulsteal.util.HudTexts;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -15,55 +17,76 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
import net.minecraft.entity.boss.BossBar; import net.minecraft.entity.boss.BossBar;
import net.minecraft.entity.boss.ServerBossBar; import net.minecraft.entity.boss.ServerBossBar;
import net.minecraft.network.packet.s2c.play.ScoreboardDisplayS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardDisplayS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardObjectiveUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardObjectiveUpdateS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardScoreResetS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardScoreResetS2CPacket;
import net.minecraft.network.packet.s2c.play.ScoreboardScoreUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.ScoreboardScoreUpdateS2CPacket;
import net.minecraft.scoreboard.Scoreboard; import net.minecraft.scoreboard.Scoreboard;
import net.minecraft.scoreboard.ScoreboardCriterion; import net.minecraft.scoreboard.ScoreboardCriterion;
import net.minecraft.scoreboard.ScoreboardDisplaySlot; import net.minecraft.scoreboard.ScoreboardDisplaySlot;
import net.minecraft.scoreboard.ScoreboardObjective; import net.minecraft.scoreboard.ScoreboardObjective;
import net.minecraft.scoreboard.number.BlankNumberFormat; import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text; import net.minecraft.text.MutableText;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */ /** Owns toggleable HUD state, player names, leaderboard data, and wanted-player bossbars. */
public final class HudService { public final class HudService {
private final Supplier<SoulStealConfig> configSupplier; private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final SoulService soulService; private final SoulService soulService;
private final BountyService bountyService; private final BountyService bountyService;
private final ContractService contractService;
private final Map<UUID, SidebarState> sidebars = new HashMap<>(); private final Map<UUID, SidebarState> sidebars = new HashMap<>();
private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>(); private final Map<UUID, ServerBossBar> bountyBossBars = new HashMap<>();
public HudService( public HudService(
Supplier<SoulStealConfig> configSupplier, Supplier<SoulStealConfig> configSupplier,
SoulStealDataStore dataStore, SoulStealDataStore dataStore,
SoulService soulService, SoulService soulService,
BountyService bountyService BountyService bountyService,
) { ContractService contractService
this.configSupplier = configSupplier; ) {
this.dataStore = dataStore; this.configSupplier = configSupplier;
this.soulService = soulService; this.dataStore = dataStore;
this.bountyService = bountyService; this.soulService = soulService;
} this.bountyService = bountyService;
this.contractService = contractService;
public void handlePlayerJoin(ServerPlayerEntity player) { }
/**
* Records player metadata and refreshes their HUD state when they join.
*
* @param player joining player
*/
public void handlePlayerJoin(ServerPlayerEntity player) {
rememberPlayer(player); rememberPlayer(player);
refreshPlayerDisplays(player, System.currentTimeMillis()); refreshPlayerDisplays(player, System.currentTimeMillis());
} }
public void handlePlayerDisconnect(ServerPlayerEntity player) { /**
* Clears any per-player HUD state when a player disconnects.
*
* @param player disconnecting player
*/
public void handlePlayerDisconnect(ServerPlayerEntity player) {
clearSidebar(player); clearSidebar(player);
clearBossBar(player); clearBossBar(player);
} }
public void tick(MinecraftServer server, long nowEpochMillis) { /**
* Refreshes active HUD elements for all online players and trims stale state.
*
* @param server current server instance
* @param nowEpochMillis current time used for countdown calculations
*/
public void tick(MinecraftServer server, long nowEpochMillis) {
Set<UUID> onlinePlayers = new HashSet<>(); Set<UUID> onlinePlayers = new HashSet<>();
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
onlinePlayers.add(player.getUuid()); onlinePlayers.add(player.getUuid());
@@ -79,14 +102,27 @@ public final class HudService {
entry.getValue().clearPlayers(); entry.getValue().clearPlayers();
return true; return true;
}); });
} }
public boolean isScoreboardVisible(UUID playerUuid) { /**
* Returns whether the given player's scoreboard sidebar is currently visible.
*
* @param playerUuid player UUID to inspect
* @return current visibility state, falling back to the config default
*/
public boolean isScoreboardVisible(UUID playerUuid) {
return dataStore.data().scoreboardVisibility() return dataStore.data().scoreboardVisibility()
.getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible()); .getOrDefault(key(playerUuid), configSupplier.get().hud().scoreboard().defaultVisible());
} }
public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) { /**
* Sets scoreboard visibility for a player and refreshes their sidebar immediately.
*
* @param player player to update
* @param visible requested visibility state
* @return the stored visibility state
*/
public boolean setScoreboardVisible(ServerPlayerEntity player, boolean visible) {
rememberPlayer(player); rememberPlayer(player);
Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible); Boolean previous = dataStore.data().scoreboardVisibility().put(key(player.getUuid()), visible);
if (!Objects.equals(previous, visible)) { if (!Objects.equals(previous, visible)) {
@@ -94,13 +130,25 @@ public final class HudService {
} }
refreshSidebar(player, System.currentTimeMillis()); refreshSidebar(player, System.currentTimeMillis());
return visible; return visible;
} }
public boolean toggleScoreboardVisible(ServerPlayerEntity player) { /**
* Flips the scoreboard visibility flag for a player.
*
* @param player player to update
* @return the updated visibility state
*/
public boolean toggleScoreboardVisible(ServerPlayerEntity player) {
return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid())); return setScoreboardVisible(player, !isScoreboardVisible(player.getUuid()));
} }
public LeaderboardPage leaderboard(int requestedPage) { /**
* Builds a leaderboard page from stored names and balances.
*
* @param requestedPage 1-based page number requested by the caller
* @return the clamped leaderboard page and its entries
*/
public LeaderboardPage leaderboard(int requestedPage) {
Set<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet()); Set<String> playerKeys = new HashSet<>(dataStore.data().playerNames().keySet());
playerKeys.addAll(dataStore.data().souls().keySet()); playerKeys.addAll(dataStore.data().souls().keySet());
@@ -186,29 +234,48 @@ public final class HudService {
BossBar.Color.RED, BossBar.Color.RED,
BossBar.Style.PROGRESS BossBar.Style.PROGRESS
)); ));
bossBar.setName(Text.literal(configSupplier.get().hud().bountyBossbar().title() + ": " + totalValue + " souls | " + DurationFormatter.formatSeconds(remainingSeconds))); MutableText bossbarText = HudTexts.title(configSupplier.get().hud().bountyBossbar().title())
bossBar.setPercent(percent); .append(Text.literal(" "))
.append(HudTexts.value(String.valueOf(totalValue), Formatting.GOLD))
.append(Text.literal(" "))
.append(HudTexts.value("souls", Formatting.GRAY))
.append(Text.literal(" "))
.append(HudTexts.value("", Formatting.DARK_GRAY))
.append(Text.literal(" "))
.append(HudTexts.value(DurationFormatter.formatSeconds(remainingSeconds), Formatting.RED));
bossBar.setName(bossbarText);
bossBar.setPercent(percent);
bossBar.setVisible(true); bossBar.setVisible(true);
if (!bossBar.getPlayers().contains(player)) { if (!bossBar.getPlayers().contains(player)) {
bossBar.addPlayer(player); bossBar.addPlayer(player);
} }
} }
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum(); List<Text> lines = new ArrayList<>();
long remainingSeconds = activeBounties.stream() lines.add(HudTexts.labeledValue("Souls", String.valueOf(soulService.balanceOf(player.getUuid())), Formatting.GOLD));
.mapToLong(StoredBounty::expiresAtEpochMillis)
.max() contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
.orElse(nowEpochMillis); long progress = contractService.progress(player.getUuid(), contract.id());
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); lines.add(HudTexts.labeledValue("Contract", contract.name(), Formatting.WHITE));
lines.add(HudTexts.labeledValue("Progress", progress + "/" + contract.amountRequired(), Formatting.GRAY));
return List.of( });
Text.literal("Souls: " + soulService.balanceOf(player.getUuid())),
Text.literal("Bounties: " + activeBounties.size()), if (!activeBounties.isEmpty()) {
Text.literal("Wanted Value: " + totalValue), long totalValue = activeBounties.stream().mapToLong(StoredBounty::soulValue).sum();
Text.literal("Wanted Time: " + (remainingSeconds > 0L ? DurationFormatter.formatSeconds(remainingSeconds) : "None")) long remainingSeconds = activeBounties.stream()
); .mapToLong(StoredBounty::expiresAtEpochMillis)
.max()
.orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
lines.add(HudTexts.labeledValue("Bounties", String.valueOf(activeBounties.size()), Formatting.RED));
lines.add(HudTexts.labeledValue("Wanted", String.valueOf(totalValue), Formatting.GOLD));
lines.add(HudTexts.labeledValue("Time Left", DurationFormatter.formatSeconds(remainingSeconds), Formatting.DARK_RED));
}
return lines;
} }
private void clearSidebar(ServerPlayerEntity player) { private void clearSidebar(ServerPlayerEntity player) {
@@ -235,14 +302,14 @@ public final class HudService {
private ScoreboardObjective createObjective(String objectiveName) { private ScoreboardObjective createObjective(String objectiveName) {
Scoreboard scoreboard = new Scoreboard(); Scoreboard scoreboard = new Scoreboard();
return scoreboard.addObjective( return scoreboard.addObjective(
objectiveName, objectiveName,
ScoreboardCriterion.DUMMY, ScoreboardCriterion.DUMMY,
Text.literal(configSupplier.get().hud().scoreboard().title()), HudTexts.title(configSupplier.get().hud().scoreboard().title()),
ScoreboardCriterion.RenderType.INTEGER, ScoreboardCriterion.RenderType.INTEGER,
false, false,
BlankNumberFormat.INSTANCE BlankNumberFormat.INSTANCE
); );
} }
private void rememberPlayer(ServerPlayerEntity player) { private void rememberPlayer(ServerPlayerEntity player) {
@@ -283,4 +350,4 @@ public final class HudService {
public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) { public record LeaderboardPage(int page, int totalPages, List<LeaderboardEntry> entries) {
} }
} }
@@ -1,17 +1,19 @@
package com.g2806.soulsteal.service; package com.g2806.soulsteal.service;
import com.g2806.soulsteal.SoulStealMod; import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer; import java.util.function.Consumer;
import net.minecraft.server.command.ServerCommandSource; import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
/** /**
* Bridges Soul Steal's permission checks to the Fabric Permissions API and optional LuckPerms. * Bridges Soul Steal's permission checks to the Fabric Permissions API and optional LuckPerms.
@@ -19,14 +21,32 @@ import net.minecraft.server.network.ServerPlayerEntity;
* <p>Permission rewards first try LuckPerms for external integrations, then optionally fall back to * <p>Permission rewards first try LuckPerms for external integrations, then optionally fall back to
* a persisted internal store so Soul Steal's own nodes continue to work without extra mods.</p> * a persisted internal store so Soul Steal's own nodes continue to work without extra mods.</p>
*/ */
public final class PermissionService { public final class PermissionService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final Supplier<PermissionConfig> permissionConfigSupplier;
public PermissionService(SoulStealDataStore dataStore) {
this.dataStore = dataStore; public PermissionService(SoulStealDataStore dataStore, Supplier<PermissionConfig> permissionConfigSupplier) {
} this.dataStore = dataStore;
this.permissionConfigSupplier = permissionConfigSupplier;
public boolean has(ServerCommandSource source, String permission, int defaultLevel) { }
public SoulStealDataStore dataStore() {
return dataStore;
}
public boolean isLuckPermsEnabled() {
return permissionConfigSupplier.get().luckpermsEnabled();
}
/**
* Checks whether a command source has a permission node.
*
* @param source permission subject
* @param permission node to check
* @param defaultLevel fallback operator level when no permission backend is available
* @return {@code true} if the source is allowed to use the permission
*/
public boolean has(ServerCommandSource source, String permission, int defaultLevel) {
if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) { if (source.getPlayer() != null && hasStoredPermission(source.getPlayer().getUuid(), permission)) {
return true; return true;
} }
@@ -37,18 +57,34 @@ public final class PermissionService {
} }
return source.getPlayer() == null || defaultLevel <= 0; return source.getPlayer() == null || defaultLevel <= 0;
} }
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) { /**
* Checks whether a source has any permission from the provided set.
*
* @param source permission subject
* @param defaultLevel fallback operator level when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerCommandSource source, int defaultLevel, String... permissions) {
for (String permission : permissions) { for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) { if (permission != null && !permission.isBlank() && has(source, permission, defaultLevel)) {
return true; return true;
} }
} }
return false; return false;
} }
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) { /**
* Checks whether a player has a permission node.
*
* @param player permission subject
* @param permission node to check
* @param defaultValue fallback value when no permission backend is available
* @return {@code true} if the player is allowed to use the permission
*/
public boolean has(ServerPlayerEntity player, String permission, boolean defaultValue) {
if (hasStoredPermission(player.getUuid(), permission)) { if (hasStoredPermission(player.getUuid(), permission)) {
return true; return true;
} }
@@ -59,19 +95,36 @@ public final class PermissionService {
} }
return defaultValue; return defaultValue;
} }
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) { /**
* Checks whether a player has any permission from the provided set.
*
* @param player permission subject
* @param defaultValue fallback value when no permission backend is available
* @param permissions candidate permissions to check
* @return {@code true} if at least one permission is granted
*/
public boolean hasAny(ServerPlayerEntity player, boolean defaultValue, String... permissions) {
for (String permission : permissions) { for (String permission : permissions) {
if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) { if (permission != null && !permission.isBlank() && has(player, permission, defaultValue)) {
return true; return true;
} }
} }
return false; return false;
} }
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) { /**
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value); * Grants a permission through LuckPerms if available and optionally stores a fallback copy.
*
* @param playerUuid player receiving the permission
* @param permission node to grant or revoke
* @param value desired node value
* @param storeFallback whether to persist the value in Soul Steal's internal store
* @return grant outcome and backend details
*/
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false; boolean storedInternally = false;
if (storeFallback) { if (storeFallback) {
@@ -91,9 +144,23 @@ public final class PermissionService {
grantedViaLuckPerms ? "Permission granted successfully." : "Permission stored in Soul Steal fallback permissions."); grantedViaLuckPerms ? "Permission granted successfully." : "Permission stored in Soul Steal fallback permissions.");
} }
return new GrantResult(false, false, false, return new GrantResult(false, false, false,
"No supported permissions backend was available for that reward."); "No supported permissions backend was available for that reward.");
} }
public RankGrantResult grantLuckPermsGroup(UUID playerUuid, String group, boolean storeFallback) {
if (!isLuckPermsEnabled()) {
return new RankGrantResult(false, false, "LuckPerms is disabled in config.");
}
boolean granted = tryGrantLuckPermsGroup(playerUuid, group);
if (!granted) {
return new RankGrantResult(false, false, "No supported permissions backend was available for that rank.");
}
if (storeFallback) {
saveQuietly();
}
return new RankGrantResult(true, storeFallback, "Rank granted successfully.");
}
private boolean hasStoredPermission(UUID playerUuid, String permission) { private boolean hasStoredPermission(UUID playerUuid, String permission) {
return dataStore.data().grantedPermissions() return dataStore.data().grantedPermissions()
@@ -101,8 +168,8 @@ public final class PermissionService {
.getOrDefault(permission, false); .getOrDefault(permission, false);
} }
private boolean tryGrantWithLuckPerms(UUID playerUuid, String permission, boolean value) { private boolean tryGrantWithLuckPerms(UUID playerUuid, String permission, boolean value) {
try { try {
Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider");
Object api = providerClass.getMethod("get").invoke(null); Object api = providerClass.getMethod("get").invoke(null);
Object userManager = api.getClass().getMethod("getUserManager").invoke(api); Object userManager = api.getClass().getMethod("getUserManager").invoke(api);
@@ -127,9 +194,46 @@ public final class PermissionService {
return false; return false;
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) { } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) {
SoulStealMod.LOGGER.warn("Failed to grant LuckPerms permission {} to {}", permission, playerUuid, exception); SoulStealMod.LOGGER.warn("Failed to grant LuckPerms permission {} to {}", permission, playerUuid, exception);
return false; return false;
} }
} }
private boolean tryGrantLuckPermsGroup(UUID playerUuid, String group) {
try {
Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider");
Object api = providerClass.getMethod("get").invoke(null);
Object userManager = api.getClass().getMethod("getUserManager").invoke(api);
Object groupManager = api.getClass().getMethod("getGroupManager").invoke(api);
Object lpGroup = groupManager.getClass().getMethod("getGroup", String.class).invoke(groupManager, group);
if (lpGroup == null) {
return false;
}
String groupName = String.valueOf(lpGroup.getClass().getMethod("getName").invoke(lpGroup));
Class<?> userClass = Class.forName("net.luckperms.api.model.user.User");
Consumer<Object> consumer = user -> {
try {
Object data = user.getClass().getMethod("data").invoke(user);
Class<?> nodeClass = Class.forName("net.luckperms.api.node.Node");
Class<?> inheritanceNodeClass = Class.forName("net.luckperms.api.node.types.InheritanceNode");
Object builder = inheritanceNodeClass.getMethod("builder", String.class).invoke(null, groupName);
Object builtNode = builder.getClass().getMethod("build").invoke(builder);
data.getClass().getMethod("add", nodeClass).invoke(data, builtNode);
userManager.getClass().getMethod("saveUser", userClass).invoke(userManager, user);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException exception) {
throw new RuntimeException(exception);
}
};
userManager.getClass().getMethod("modifyUser", UUID.class, Consumer.class).invoke(userManager, playerUuid, consumer);
return true;
} catch (ClassNotFoundException exception) {
return false;
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) {
SoulStealMod.LOGGER.warn("Failed to grant LuckPerms group {} to {}", group, playerUuid, exception);
return false;
}
}
private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) { private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) {
try { try {
@@ -192,6 +296,9 @@ public final class PermissionService {
return type; return type;
} }
public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) { public record GrantResult(boolean success, boolean grantedViaLuckPerms, boolean storedInternally, String message) {
} }
}
public record RankGrantResult(boolean success, boolean storedInternally, String message) {
}
}
@@ -1,14 +1,18 @@
package com.g2806.soulsteal.service; package com.g2806.soulsteal.service;
import com.g2806.soulsteal.shop.CommandRewardDefinition; import com.g2806.soulsteal.shop.CommandRewardDefinition;
import com.g2806.soulsteal.shop.EffectRewardDefinition; import com.g2806.soulsteal.shop.EffectRewardDefinition;
import com.g2806.soulsteal.shop.ItemRewardDefinition; import com.g2806.soulsteal.shop.ItemRewardDefinition;
import com.g2806.soulsteal.shop.PermissionRewardDefinition; import com.g2806.soulsteal.shop.PermissionRewardDefinition;
import com.g2806.soulsteal.shop.RewardDefinition; import com.g2806.soulsteal.shop.RewardDefinition;
import com.g2806.soulsteal.shop.StackMode; import com.g2806.soulsteal.shop.StackMode;
import java.util.ArrayList; import com.g2806.soulsteal.shop.RankRewardDefinition;
import java.util.List; import java.io.IOException;
import java.util.Objects; import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.minecraft.component.DataComponentTypes; import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent; import net.minecraft.component.type.LoreComponent;
import net.minecraft.entity.effect.StatusEffect; import net.minecraft.entity.effect.StatusEffect;
@@ -29,8 +33,8 @@ import net.minecraft.world.World;
/** Executes validated shop rewards for the player who bought an entry. */ /** Executes validated shop rewards for the player who bought an entry. */
public final class RewardService { public final class RewardService {
private final PermissionService permissionService; private final PermissionService permissionService;
private final SoulService soulService; private final SoulService soulService;
public RewardService(PermissionService permissionService, SoulService soulService) { public RewardService(PermissionService permissionService, SoulService soulService) {
this.permissionService = permissionService; this.permissionService = permissionService;
@@ -50,15 +54,20 @@ public final class RewardService {
return new ValidationResult(false, "Unknown status effect id: " + effectReward.effectId()); return new ValidationResult(false, "Unknown status effect id: " + effectReward.effectId());
} }
} }
case PermissionRewardDefinition permissionReward -> { case PermissionRewardDefinition permissionReward -> {
if (permissionReward.node().isBlank()) { if (permissionReward.node().isBlank()) {
return new ValidationResult(false, "Permission rewards require a non-empty node."); return new ValidationResult(false, "Permission rewards require a non-empty node.");
} }
} }
case CommandRewardDefinition commandReward -> { case RankRewardDefinition rankReward -> {
if (commandReward.command().isBlank()) { if (rankReward.command().isBlank()) {
return new ValidationResult(false, "Command rewards require a non-empty command string."); return new ValidationResult(false, "Rank rewards require a non-empty command.");
} }
}
case CommandRewardDefinition commandReward -> {
if (commandReward.command().isBlank()) {
return new ValidationResult(false, "Command rewards require a non-empty command string.");
}
} }
} }
} }
@@ -87,18 +96,25 @@ public final class RewardService {
applyEffectReward(player, effectEntry, effectReward); applyEffectReward(player, effectEntry, effectReward);
granted.add(rewardDisplayName(effectReward)); granted.add(rewardDisplayName(effectReward));
} }
case PermissionRewardDefinition permissionReward -> { case PermissionRewardDefinition permissionReward -> {
PermissionService.GrantResult result = permissionService.grantPersistentPermission( PermissionService.GrantResult result = permissionService.grantPersistentPermission(
player.getUuid(), permissionReward.node(), permissionReward.value(), permissionReward.storeFallback()); player.getUuid(), permissionReward.node(), permissionReward.value(), permissionReward.storeFallback());
if (!result.success()) { if (!result.success()) {
return new GrantResult(false, result.message(), granted); return new GrantResult(false, result.message(), granted);
} }
granted.add(rewardDisplayName(permissionReward)); granted.add(rewardDisplayName(permissionReward));
} }
case CommandRewardDefinition commandReward -> { case RankRewardDefinition rankReward -> {
executeCommandReward(player, commandReward); GrantResult rankResult = grantRankReward(player, rankReward);
granted.add(rewardDisplayName(commandReward)); if (!rankResult.success()) {
} return new GrantResult(false, rankResult.message(), granted);
}
granted.add(rewardDisplayName(rankReward));
}
case CommandRewardDefinition commandReward -> {
executeCommandReward(player, commandReward);
granted.add(rewardDisplayName(commandReward));
}
} }
} }
@@ -111,11 +127,12 @@ public final class RewardService {
switch (reward) { switch (reward) {
case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY)); case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY));
case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY)); case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY));
case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY)); case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY));
case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY)); case RankRewardDefinition rankReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(rankReward)).formatted(Formatting.GRAY));
} case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY));
} }
return lines; }
return lines;
} }
public boolean supportsCustomAmount(List<RewardDefinition> rewards) { public boolean supportsCustomAmount(List<RewardDefinition> rewards) {
@@ -215,12 +232,19 @@ public final class RewardService {
return reward.effectId(); return reward.effectId();
} }
private String rewardDisplayName(PermissionRewardDefinition reward) { private String rewardDisplayName(PermissionRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) { if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName(); return reward.displayName();
} }
return "permission " + reward.node(); return "permission " + reward.node();
} }
private String rewardDisplayName(RankRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName();
}
return "rank command";
}
private String rewardDisplayName(CommandRewardDefinition reward) { private String rewardDisplayName(CommandRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) { if (reward.displayName() != null && !reward.displayName().isBlank()) {
@@ -245,17 +269,37 @@ public final class RewardService {
return Registries.ITEM.get(identifier); return Registries.ITEM.get(identifier);
} }
private RegistryEntry<StatusEffect> resolveStatusEffect(String effectId) { private RegistryEntry<StatusEffect> resolveStatusEffect(String effectId) {
Identifier identifier = Identifier.tryParse(effectId); Identifier identifier = Identifier.tryParse(effectId);
if (identifier == null) { if (identifier == null) {
return null; return null;
} }
return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null); return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null);
} }
private GrantResult grantRankReward(ServerPlayerEntity player, RankRewardDefinition reward) {
String playerKey = player.getUuidAsString();
int currentPriority = permissionService.dataStore().data().grantedRankPriorities().getOrDefault(playerKey, Integer.MIN_VALUE);
if (currentPriority >= reward.priority()) {
return new GrantResult(false, "You already own an equal or higher rank.", List.of());
}
permissionService.dataStore().data().grantedRankPriorities().put(playerKey, reward.priority());
try {
permissionService.dataStore().save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist rank reward data.", exception);
}
executeCommandReward(player, new CommandRewardDefinition(
reward.command(),
reward.runAsConsole(),
reward.displayName()
));
return new GrantResult(true, "Rank command executed successfully.", List.of(reward.command()));
}
public record ValidationResult(boolean success, String message) { public record ValidationResult(boolean success, String message) {
} }
public record GrantResult(boolean success, String message, List<String> grantedRewards) { public record GrantResult(boolean success, String message, List<String> grantedRewards) {
} }
} }
@@ -49,36 +49,57 @@ public final class ShopService {
private final RewardService rewardService; private final RewardService rewardService;
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
public ShopService( public ShopService(
Supplier<ConfigBundle> bundleSupplier, Supplier<ConfigBundle> bundleSupplier,
SoulService soulService, SoulService soulService,
RewardService rewardService, RewardService rewardService,
SoulStealDataStore dataStore SoulStealDataStore dataStore
) { ) {
this.bundleSupplier = bundleSupplier; this.bundleSupplier = bundleSupplier;
this.soulService = soulService; this.soulService = soulService;
this.rewardService = rewardService; this.rewardService = rewardService;
this.dataStore = dataStore; this.dataStore = dataStore;
} }
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) { /**
* Opens the shop UI for either the home view or a specific category.
*
* @param player player to show the UI to
* @param requestedCategoryKey category key to open, or {@code null} for the home view
* @param requestedPage zero-based page index to display
*/
public void openShop(ServerPlayerEntity player, String requestedCategoryKey, int requestedPage) {
if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) { if (requestedCategoryKey == null || requestedCategoryKey.isBlank()) {
openView(player, resolveHomeView(requestedPage)); openView(player, resolveHomeView(requestedPage));
return; return;
} }
openView(player, resolveCategoryView(requestedCategoryKey, requestedPage)); openView(player, resolveCategoryView(requestedCategoryKey, requestedPage));
} }
public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) { /**
* Builds the backing inventory for a specific shop view.
*
* @param player player who will interact with the inventory
* @param view resolved shop view state
* @return inventory contents appropriate for the supplied view
*/
public SimpleInventory createInventory(ServerPlayerEntity player, ShopView view) {
return switch (view) { return switch (view) {
case HomeView homeView -> createHomeInventory(player, homeView); case HomeView homeView -> createHomeInventory(player, homeView);
case CategoryView categoryView -> createCategoryInventory(player, categoryView); case CategoryView categoryView -> createCategoryInventory(player, categoryView);
case AmountView amountView -> createAmountInventory(player, amountView); case AmountView amountView -> createAmountInventory(player, amountView);
}; };
} }
public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) { /**
* Dispatches a click inside the shop GUI to the correct view handler.
*
* @param player player interacting with the shop
* @param view current resolved view
* @param slotIndex clicked slot index
*/
public void handleClick(ServerPlayerEntity player, ShopView view, int slotIndex) {
switch (view) { switch (view) {
case HomeView homeView -> handleHomeClick(player, homeView, slotIndex); case HomeView homeView -> handleHomeClick(player, homeView, slotIndex);
case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex); case CategoryView categoryView -> handleCategoryClick(player, categoryView, slotIndex);
@@ -525,4 +546,4 @@ public final class ShopService {
public record PurchaseResult(boolean success, String message) { public record PurchaseResult(boolean success, String message) {
} }
} }
@@ -13,21 +13,41 @@ public final class SoulService {
private final Supplier<SoulStealConfig> configSupplier; private final Supplier<SoulStealConfig> configSupplier;
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
public SoulService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore) { public SoulService(Supplier<SoulStealConfig> configSupplier, SoulStealDataStore dataStore) {
this.configSupplier = configSupplier; this.configSupplier = configSupplier;
this.dataStore = dataStore; this.dataStore = dataStore;
} }
public long balanceOf(UUID playerUuid) { /**
* Looks up the stored balance for a player UUID.
*
* @param playerUuid player identifier to inspect
* @return the current balance, or the configured starting balance for new players
*/
public long balanceOf(UUID playerUuid) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy(); SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls()); return dataStore.data().souls().getOrDefault(key(playerUuid), economy.startingSouls());
} }
public boolean hasSouls(UUID playerUuid, long amount) { /**
* Checks whether a player currently has at least the requested amount of souls.
*
* @param playerUuid player identifier to inspect
* @param amount amount to compare against
* @return {@code true} when the player has enough souls
*/
public boolean hasSouls(UUID playerUuid, long amount) {
return balanceOf(playerUuid) >= Math.max(0L, amount); return balanceOf(playerUuid) >= Math.max(0L, amount);
} }
public long addSouls(UUID playerUuid, long amount) { /**
* Adds souls to a player's balance and clamps the result to the configured maximum.
*
* @param playerUuid player identifier to update
* @param amount amount to add
* @return the updated balance after clamping
*/
public long addSouls(UUID playerUuid, long amount) {
if (amount <= 0L) { if (amount <= 0L) {
return balanceOf(playerUuid); return balanceOf(playerUuid);
} }
@@ -37,9 +57,16 @@ public final class SoulService {
long updated = Math.min(economy.maxSouls(), current + amount); long updated = Math.min(economy.maxSouls(), current + amount);
updateBalance(playerUuid, updated); updateBalance(playerUuid, updated);
return updated; return updated;
} }
public long removeSouls(UUID playerUuid, long amount) { /**
* Removes souls from a player's balance and clamps the result at zero.
*
* @param playerUuid player identifier to update
* @param amount amount to remove
* @return the updated balance after clamping
*/
public long removeSouls(UUID playerUuid, long amount) {
if (amount <= 0L) { if (amount <= 0L) {
return balanceOf(playerUuid); return balanceOf(playerUuid);
} }
@@ -48,14 +75,26 @@ public final class SoulService {
long updated = Math.max(0L, current - amount); long updated = Math.max(0L, current - amount);
updateBalance(playerUuid, updated); updateBalance(playerUuid, updated);
return updated; return updated;
} }
public void setSouls(UUID playerUuid, long amount) { /**
* Sets a player's balance directly, applying the configured upper bound.
*
* @param playerUuid player identifier to update
* @param amount requested new balance
*/
public void setSouls(UUID playerUuid, long amount) {
SoulStealConfig.EconomyConfig economy = configSupplier.get().economy(); SoulStealConfig.EconomyConfig economy = configSupplier.get().economy();
updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount))); updateBalance(playerUuid, Math.max(0L, Math.min(economy.maxSouls(), amount)));
} }
public SoulChange applyDeathPenalty(UUID playerUuid) { /**
* Applies the configured death penalty to a player.
*
* @param playerUuid player identifier to penalize
* @return the amount removed and the new balance after applying the penalty
*/
public SoulChange applyDeathPenalty(UUID playerUuid) {
long current = balanceOf(playerUuid); long current = balanceOf(playerUuid);
if (current <= 0L) { if (current <= 0L) {
return new SoulChange(0L, 0L); return new SoulChange(0L, 0L);
@@ -71,9 +110,17 @@ public final class SoulService {
long newBalance = current - boundedLoss; long newBalance = current - boundedLoss;
updateBalance(playerUuid, newBalance); updateBalance(playerUuid, newBalance);
return new SoulChange(-boundedLoss, newBalance); return new SoulChange(-boundedLoss, newBalance);
} }
public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) { /**
* Transfers souls between two players if the transfer rules allow it.
*
* @param senderUuid source player UUID
* @param receiverUuid destination player UUID
* @param amount amount to transfer
* @return the transfer result, including balances and a human-readable message
*/
public TransferResult transfer(UUID senderUuid, UUID receiverUuid, long amount) {
SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer(); SoulStealConfig.TransferConfig transferConfig = configSupplier.get().economy().transfer();
if (!transferConfig.enabled()) { if (!transferConfig.enabled()) {
return new TransferResult(false, "Soul transfers are disabled on this server.", balanceOf(senderUuid), balanceOf(receiverUuid)); return new TransferResult(false, "Soul transfers are disabled on this server.", balanceOf(senderUuid), balanceOf(receiverUuid));
@@ -113,4 +160,4 @@ public final class SoulService {
public record TransferResult(boolean success, String message, long senderBalance, long receiverBalance) { public record TransferResult(boolean success, String message, long senderBalance, long receiverBalance) {
} }
} }
@@ -0,0 +1,9 @@
package com.g2806.soulsteal.shop;
/** Rank reward backed by a command and a track priority. */
public record RankRewardDefinition(String command, int priority, boolean runAsConsole, String displayName) implements RewardDefinition {
@Override
public RewardType type() {
return RewardType.RANK;
}
}
@@ -1,6 +1,6 @@
package com.g2806.soulsteal.shop; package com.g2806.soulsteal.shop;
/** Marker interface for all shop reward definitions. */ /** Marker interface for all shop reward definitions. */
public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition { public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition, RankRewardDefinition {
RewardType type(); RewardType type();
} }
@@ -1,9 +1,10 @@
package com.g2806.soulsteal.shop; package com.g2806.soulsteal.shop;
/** Supported reward types that can be granted by the soul shop. */ /** Supported reward types that can be granted by the soul shop. */
public enum RewardType { public enum RewardType {
ITEM, ITEM,
PERMISSION, PERMISSION,
EFFECT, EFFECT,
COMMAND COMMAND,
} RANK
}
@@ -1,17 +1,18 @@
package com.g2806.soulsteal.shop; package com.g2806.soulsteal.shop;
import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig; import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig;
import com.g2806.soulsteal.config.YamlConfigHelper; import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** Parsed representation of the editable shop catalog. */ /** Parsed representation of the editable shop catalog. */
public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) { public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) {
public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi) { public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi, PermissionConfig permissionConfig) {
Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories"); Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories");
List<ShopCategoryDefinition> categories = new ArrayList<>(); List<ShopCategoryDefinition> categories = new ArrayList<>();
@@ -30,7 +31,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
} }
Map<String, Object> itemMap = toStringMap(rawItemMap); Map<String, Object> itemMap = toStringMap(rawItemMap);
List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards")); List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards"), permissionConfig.luckpermsEnabled());
if (rewards.isEmpty()) { if (rewards.isEmpty()) {
continue; continue;
} }
@@ -58,7 +59,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
)); ));
} }
return new ShopCatalog(shopUi.title(), shopUi.rows(), shopUi.fillerItemId(), categories); return new ShopCatalog(shopUi.title(), shopUi.rows(), shopUi.fillerItemId(), categories);
} }
public Optional<ShopCategoryDefinition> category(String key) { public Optional<ShopCategoryDefinition> category(String key) {
@@ -111,11 +112,11 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
stack_mode: ADD_DURATION stack_mode: ADD_DURATION
name: "Speed Boost" name: "Speed Boost"
unlocks: unlocks:
name: "Unlocks" name: "Unlocks"
icon: "minecraft:nether_star" icon: "minecraft:nether_star"
items: items:
nickname_access: nickname_access:
slot: 13 slot: 13
icon: "minecraft:name_tag" icon: "minecraft:name_tag"
name: "Nickname Access" name: "Nickname Access"
@@ -124,17 +125,33 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
- "Requires LuckPerms for external permissions." - "Requires LuckPerms for external permissions."
cost: 1000 cost: 1000
repeatable: false repeatable: false
rewards: rewards:
- type: permission - type: permission
node: "example.nick" node: "example.nick"
value: true value: true
store_fallback: true store_fallback: true
name: "Nickname Permission" name: "Nickname Permission"
utility_commands: vip_rank:
name: "Command Hooks" slot: 14
icon: "minecraft:command_block" icon: "minecraft:diamond_chestplate"
items: name: "VIP Rank"
description:
- "Runs a command when bought."
- "Ranks are tracked by priority so lower ranks cannot overwrite higher ones."
cost: 5000
repeatable: false
rewards:
- type: rank
priority: 20
run_as_console: true
command: "lp user %player% parent add vip"
name: "VIP Rank"
utility_commands:
name: "Command Hooks"
icon: "minecraft:command_block"
items:
starter_crate: starter_crate:
slot: 15 slot: 15
icon: "minecraft:chest" icon: "minecraft:chest"
@@ -152,7 +169,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
"""; """;
} }
private static List<RewardDefinition> parseRewards(List<Object> rawRewards) { private static List<RewardDefinition> parseRewards(List<Object> rawRewards, boolean luckPermsEnabled) {
List<RewardDefinition> rewards = new ArrayList<>(); List<RewardDefinition> rewards = new ArrayList<>();
for (Object rewardValue : rawRewards) { for (Object rewardValue : rawRewards) {
@@ -176,16 +193,27 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
Math.max(1, YamlConfigHelper.intValue(rewardMap, "amount", 1)), Math.max(1, YamlConfigHelper.intValue(rewardMap, "amount", 1)),
rewardName rewardName
)); ));
case PERMISSION -> rewards.add(new PermissionRewardDefinition( case PERMISSION -> rewards.add(new PermissionRewardDefinition(
YamlConfigHelper.string(rewardMap, "node", "soulsteal.example"), YamlConfigHelper.string(rewardMap, "node", "soulsteal.example"),
YamlConfigHelper.bool(rewardMap, "value", true), YamlConfigHelper.bool(rewardMap, "value", true),
YamlConfigHelper.bool(rewardMap, "store_fallback", true), YamlConfigHelper.bool(rewardMap, "store_fallback", true),
rewardName rewardName
)); ));
case EFFECT -> { case RANK -> {
StackMode stackMode; if (!luckPermsEnabled) {
try { continue;
stackMode = StackMode.valueOf(YamlConfigHelper.string(rewardMap, "stack_mode", StackMode.REPLACE.name()).toUpperCase(Locale.ROOT)); }
rewards.add(new RankRewardDefinition(
YamlConfigHelper.string(rewardMap, "command", "say %player% purchased a rank."),
Math.max(0, YamlConfigHelper.intValue(rewardMap, "priority", 0)),
YamlConfigHelper.bool(rewardMap, "run_as_console", true),
rewardName
));
}
case EFFECT -> {
StackMode stackMode;
try {
stackMode = StackMode.valueOf(YamlConfigHelper.string(rewardMap, "stack_mode", StackMode.REPLACE.name()).toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
stackMode = StackMode.REPLACE; stackMode = StackMode.REPLACE;
} }
@@ -226,4 +254,4 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
} }
return converted; return converted;
} }
} }
@@ -2,10 +2,16 @@ package com.g2806.soulsteal.util;
/** Formats small configuration-driven durations for chat messages and shop tooltips. */ /** Formats small configuration-driven durations for chat messages and shop tooltips. */
public final class DurationFormatter { public final class DurationFormatter {
private DurationFormatter() { private DurationFormatter() {
} }
public static String formatSeconds(long totalSeconds) { /**
* Formats a duration in seconds into a compact human-readable string.
*
* @param totalSeconds duration to format
* @return formatted duration such as {@code 2h 5m 10s}
*/
public static String formatSeconds(long totalSeconds) {
if (totalSeconds <= 0L) { if (totalSeconds <= 0L) {
return "0s"; return "0s";
} }
@@ -33,4 +39,4 @@ public final class DurationFormatter {
} }
builder.append(value).append(suffix); builder.append(value).append(suffix);
} }
} }
@@ -0,0 +1,31 @@
package com.g2806.soulsteal.util;
import net.minecraft.text.MutableText;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
/** Text helpers for sidebar, bossbar, and other compact HUD surfaces. */
public final class HudTexts {
private HudTexts() {
}
public static MutableText title(String text) {
return Text.literal(text).formatted(Formatting.DARK_AQUA, Formatting.BOLD);
}
public static MutableText label(String text) {
return Text.literal(text).formatted(Formatting.AQUA, Formatting.BOLD);
}
public static MutableText value(String text, Formatting formatting) {
return Text.literal(text).formatted(formatting);
}
public static MutableText labeledValue(String label, String value, Formatting valueFormatting) {
return label(label).append(Text.literal(" ")).append(value(value, valueFormatting));
}
public static MutableText separator() {
return Text.literal(" ").formatted(Formatting.GRAY);
}
}
@@ -6,26 +6,56 @@ import net.minecraft.util.Formatting;
/** Centralized chat text helpers so command and gameplay messaging stay consistent. */ /** Centralized chat text helpers so command and gameplay messaging stay consistent. */
public final class SoulTexts { public final class SoulTexts {
private SoulTexts() { private SoulTexts() {
} }
public static Text info(String message) { /**
* Builds an informational chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text info(String message) {
return prefixed(message, Formatting.GRAY); return prefixed(message, Formatting.GRAY);
} }
public static Text success(String message) { /**
* Builds a success chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text success(String message) {
return prefixed(message, Formatting.GREEN); return prefixed(message, Formatting.GREEN);
} }
public static Text warning(String message) { /**
* Builds a warning chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text warning(String message) {
return prefixed(message, Formatting.GOLD); return prefixed(message, Formatting.GOLD);
} }
public static Text error(String message) { /**
* Builds an error chat message.
*
* @param message message body without the shared prefix
* @return formatted chat text
*/
public static Text error(String message) {
return prefixed(message, Formatting.RED); return prefixed(message, Formatting.RED);
} }
public static MutableText accent(String message) { /**
* Builds highlighted accent text without the standard prefix.
*
* @param message text to accent
* @return formatted text component
*/
public static MutableText accent(String message) {
return Text.literal(message).formatted(Formatting.AQUA); return Text.literal(message).formatted(Formatting.AQUA);
} }
@@ -33,4 +63,4 @@ public final class SoulTexts {
return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA) return Text.literal("[Soul Steal] ").formatted(Formatting.DARK_AQUA)
.append(Text.literal(message).formatted(formatting)); .append(Text.literal(message).formatted(formatting));
} }
} }
@@ -0,0 +1,21 @@
package com.g2806.soulsteal;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SoulStealModTest {
@Test
void placedBlockPositionUsesClickedFaceOffset() {
BlockPos clicked = new BlockPos(10, 64, -3);
assertEquals(new BlockPos(10, 65, -3), SoulStealMod.placedBlockPos(clicked, Direction.UP));
assertEquals(new BlockPos(10, 63, -3), SoulStealMod.placedBlockPos(clicked, Direction.DOWN));
assertEquals(new BlockPos(11, 64, -3), SoulStealMod.placedBlockPos(clicked, Direction.EAST));
assertEquals(new BlockPos(9, 64, -3), SoulStealMod.placedBlockPos(clicked, Direction.WEST));
assertEquals(new BlockPos(10, 64, -4), SoulStealMod.placedBlockPos(clicked, Direction.NORTH));
assertEquals(new BlockPos(10, 64, -2), SoulStealMod.placedBlockPos(clicked, Direction.SOUTH));
}
}
@@ -0,0 +1,38 @@
package com.g2806.soulsteal.contract;
import com.g2806.soulsteal.config.SoulStealConfig;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ContractCatalogTest {
@Test
void parsesCooldownAndDisplayMatches() {
Map<String, Object> root = Map.of(
"contracts", Map.of(
"mining", List.of(Map.of(
"id", "iron_miner",
"type", "mining",
"name", "Iron Miner",
"targets", List.of("minecraft:iron_ore", "minecraft:deepslate_iron_ore"),
"matches", List.of("Iron Ore", "Deepslate Iron Ore"),
"target_name", "Iron Ore",
"amount", 20,
"reward", 200,
"repeatable", true,
"cooldown", 10
))
)
);
SoulStealConfig.ContractConfig config = new SoulStealConfig.ContractConfig(true, true, new SoulStealConfig.ContractHudConfig(true, "Active Contract"), 0L);
ContractCatalog catalog = ContractCatalog.fromMap(root, config);
ContractDefinition contract = catalog.contract("iron_miner").orElseThrow();
assertEquals(10L, contract.cooldownSeconds());
assertEquals(List.of("Iron Ore", "Deepslate Iron Ore"), contract.displayMatches());
assertEquals(2, contract.targetIds().size());
}
}
@@ -0,0 +1,98 @@
package com.g2806.soulsteal.service;
import com.g2806.soulsteal.contract.ContractCatalog;
import com.g2806.soulsteal.contract.ContractDefinition;
import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore;
import java.util.LinkedHashMap;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ContractServiceTest {
@Test
void repeatableContractIsBlockedUntilCooldownExpires() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("repeatable", true, 30L, List.of("minecraft:iron_ore"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
Map<String, Long> cooldowns = new LinkedHashMap<>();
cooldowns.put("repeatable", System.currentTimeMillis() + 60_000L);
dataStore.data().contractCooldowns().put(player.toString(), cooldowns);
assertFalse(service.isContractAvailable(player, contract));
cooldowns = new LinkedHashMap<>();
cooldowns.put("repeatable", System.currentTimeMillis() - 1L);
dataStore.data().contractCooldowns().put(player.toString(), cooldowns);
assertTrue(service.isContractAvailable(player, contract));
}
@Test
void completedOneTimeContractIsUnavailable() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("one_time", false, 0L, List.of("minecraft:zombie"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
java.util.Set<String> completed = new java.util.HashSet<>();
completed.add("one_time");
dataStore.data().completedContracts().put(player.toString(), completed);
assertFalse(service.isContractAvailable(player, contract));
}
@Test
void selectedContractReadsFromPersistentData() throws Exception {
SoulStealDataStore dataStore = newDataStore();
SoulService soulService = newSoulService(dataStore);
ContractDefinition contract = contract("iron_miner", true, 0L, List.of("minecraft:iron_ore"));
ContractService service = new ContractService(() -> catalog(contract), dataStore, soulService);
UUID player = UUID.randomUUID();
dataStore.data().selectedContracts().put(player.toString(), "iron_miner");
assertTrue(service.selectedContract(player).isPresent());
assertEquals("iron_miner", service.selectedContract(player).orElseThrow().id());
}
private static ContractDefinition contract(String id, boolean repeatable, long cooldownSeconds, List<String> targets) {
return new ContractDefinition(id, id, "minecraft:iron_pickaxe", ContractType.MINING, targets, List.of(), "Iron Ore", "", 20L, 250L, repeatable, cooldownSeconds);
}
private static ContractCatalog catalog(ContractDefinition contract) {
return new ContractCatalog(true, true, true, "Active Contract", List.of(contract));
}
private static SoulStealDataStore newDataStore() throws Exception {
Path dir = Files.createTempDirectory("soulsteal-contract-test");
SoulStealDataStore store = new SoulStealDataStore(dir);
store.load();
return store;
}
private static SoulService newSoulService(SoulStealDataStore dataStore) {
return new SoulService(() -> new com.g2806.soulsteal.config.SoulStealConfig(
new com.g2806.soulsteal.config.SoulStealConfig.EconomyConfig(0L, 1_000_000L, 25L,
new com.g2806.soulsteal.config.SoulStealConfig.DeathPenaltyConfig(15L, 0.10D, 5L, 100L),
new com.g2806.soulsteal.config.SoulStealConfig.TransferConfig(true, 1L)),
new com.g2806.soulsteal.config.SoulStealConfig.BountyConfig(true, 25L, 10_000L, 7_200L, 600L, 86_400L, 0.50D, 60L, 5, 3),
new com.g2806.soulsteal.config.SoulStealConfig.TrackerConfig(true, 900L, 20, false),
new com.g2806.soulsteal.config.SoulStealConfig.ContractConfig(true, true, new com.g2806.soulsteal.config.SoulStealConfig.ContractHudConfig(true, "Active Contract"), 0L),
new com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig("Soul Shop", 3, "minecraft:black_stained_glass_pane", 0L, true, 64),
new com.g2806.soulsteal.config.SoulStealConfig.HudConfig(
new com.g2806.soulsteal.config.SoulStealConfig.ScoreboardConfig(true, false, "Soul HUD"),
new com.g2806.soulsteal.config.SoulStealConfig.BountyBossbarConfig(true, "Bounty on You"),
new com.g2806.soulsteal.config.SoulStealConfig.LeaderboardConfig(10)
),
new com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", true, "balance", "set", "add", "take", "scoreboard", "leaderboard")
), dataStore);
}
}
@@ -0,0 +1,78 @@
package com.g2806.soulsteal.shop;
import com.g2806.soulsteal.config.SoulStealConfig;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ShopCatalogTest {
@Test
void filtersRankRewardsWhenLuckPermsIsDisabled() {
Map<String, Object> root = Map.of(
"categories", Map.of(
"ranks", Map.of(
"name", "Ranks",
"items", Map.of(
"vip", Map.of(
"name", "VIP",
"cost", 1000,
"repeatable", false,
"rewards", List.of(Map.of(
"type", "rank",
"priority", 20,
"run_as_console", true,
"command", "lp user %player% parent add vip",
"name", "VIP Rank"
))
)
)
)
)
);
SoulStealConfig.ShopUiConfig shopUi = new SoulStealConfig.ShopUiConfig("Shop", 3, "minecraft:glass", 0L, true, 64);
SoulStealConfig.PermissionConfig permissions = new SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", false, "balance", "set", "add", "take", "scoreboard", "leaderboard");
ShopCatalog catalog = ShopCatalog.fromMap(root, shopUi, permissions);
assertTrue(catalog.category("ranks").orElseThrow().entries().isEmpty());
}
@Test
void parsesRankRewardsWhenLuckPermsIsEnabled() {
Map<String, Object> root = Map.of(
"categories", Map.of(
"ranks", Map.of(
"name", "Ranks",
"items", Map.of(
"vip", Map.of(
"name", "VIP",
"cost", 1000,
"repeatable", false,
"rewards", List.of(Map.of(
"type", "rank",
"priority", 20,
"run_as_console", true,
"command", "lp user %player% parent add vip",
"name", "VIP Rank"
))
)
)
)
)
);
SoulStealConfig.ShopUiConfig shopUi = new SoulStealConfig.ShopUiConfig("Shop", 3, "minecraft:glass", 0L, true, 64);
SoulStealConfig.PermissionConfig permissions = new SoulStealConfig.PermissionConfig("admin", "reload", "shop", "bounty", true, "balance", "set", "add", "take", "scoreboard", "leaderboard");
ShopCatalog catalog = ShopCatalog.fromMap(root, shopUi, permissions);
ShopEntryDefinition entry = catalog.category("ranks").orElseThrow().entries().get(0);
assertEquals(1, entry.rewards().size());
assertInstanceOf(RankRewardDefinition.class, entry.rewards().get(0));
RankRewardDefinition reward = (RankRewardDefinition) entry.rewards().get(0);
assertEquals(20, reward.priority());
assertEquals("lp user %player% parent add vip", reward.command());
assertTrue(reward.runAsConsole());
}
}
@@ -0,0 +1,15 @@
package com.g2806.soulsteal.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DurationFormatterTest {
@Test
void formatsSecondsIntoReadableUnits() {
assertEquals("10s", DurationFormatter.formatSeconds(10));
assertEquals("10m", DurationFormatter.formatSeconds(600));
assertEquals("2h", DurationFormatter.formatSeconds(7_200));
assertEquals("1d 3h", DurationFormatter.formatSeconds(97_200));
}
}