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