2 Commits

Author SHA1 Message Date
darwincereska 13fd72d304 feat: revamped hud
Release / build-and-release (push) Successful in 1m48s
2026-05-10 15:54:00 -04:00
darwincereska dce135c857 feat: add contract cooldowns, mining target matches, and rank command rewards 2026-05-10 15:44:34 -04:00
24 changed files with 851 additions and 216 deletions
+1
View File
@@ -2,6 +2,7 @@
.gradle/ .gradle/
build/ build/
out/ out/
logs/
# Loom / Mod Dev Gradle caches # Loom / Mod Dev Gradle caches
.loom-cache/ .loom-cache/
+8
View File
@@ -28,6 +28,10 @@ dependencies {
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}" implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}" include "org.yaml:snakeyaml:${project.snakeyaml_version}"
testImplementation platform("org.junit:junit-bom:5.11.4")
testImplementation "org.junit.jupiter:junit-jupiter"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
} }
processResources { processResources {
@@ -42,6 +46,10 @@ tasks.withType(JavaCompile).configureEach {
it.options.release = 21 it.options.release = 21
} }
test {
useJUnitPlatform()
}
java { java {
withSourcesJar() withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
+1 -1
View File
@@ -10,7 +10,7 @@ loom_version=1.16.1
fabric_api_version=0.141.3+1.21.11 fabric_api_version=0.141.3+1.21.11
# Mod Properties # Mod Properties
mod_version=0.4.1 mod_version=0.5.0
maven_group=com.g2806.soulsteal maven_group=com.g2806.soulsteal
archives_base_name=soul-steal archives_base_name=soul-steal
@@ -24,13 +24,18 @@ 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.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; 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.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.entity.damage.DamageSource; 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.registry.Registries;
import net.minecraft.util.ActionResult;
import net.minecraft.util.math.Direction;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -56,7 +61,6 @@ public final class SoulStealMod implements ModInitializer {
private TrackerCompassService trackerCompassService; private TrackerCompassService trackerCompassService;
private ShopService shopService; private ShopService shopService;
private HudService hudService; private HudService hudService;
/** /**
* Initializes the mod, loads configuration and persistent state, and registers all runtime * Initializes the mod, loads configuration and persistent state, and registers all runtime
* event handlers. * event handlers.
@@ -74,7 +78,7 @@ public final class SoulStealMod implements ModInitializer {
throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception); throw new UncheckedIOException("Failed to load Soul Steal configuration or persistent data.", exception);
} }
permissionService = new PermissionService(dataStore); permissionService = new PermissionService(dataStore, () -> this.config().permissions());
soulService = new SoulService(this::config, dataStore); soulService = new SoulService(this::config, dataStore);
bountyService = new BountyService(this::config, dataStore, soulService); bountyService = new BountyService(this::config, dataStore, soulService);
contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService); contractService = new ContractService(() -> this.bundle().contractCatalog(), dataStore, soulService);
@@ -102,8 +106,36 @@ public final class SoulStealMod implements ModInitializer {
contractService.recordHunting(killer, Registries.ENTITY_TYPE.getId(entity.getType()).toString()); 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) -> { PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, blockEntity) -> {
if (player instanceof ServerPlayerEntity serverPlayer) { 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()); contractService.recordMining(serverPlayer, Registries.BLOCK.getId(state.getBlock()).toString());
} }
}); });
@@ -185,6 +217,28 @@ public final class SoulStealMod implements ModInitializer {
} }
} }
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. * Returns the loaded configuration bundle.
* *
@@ -16,7 +16,7 @@ public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog, Cont
SoulStealConfig config = SoulStealConfig.fromMap(configMap); SoulStealConfig config = SoulStealConfig.fromMap(configMap);
Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml()); Map<String, Object> shopMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("shop.yml"), ShopCatalog.defaultYaml());
ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop()); ShopCatalog shopCatalog = ShopCatalog.fromMap(shopMap, config.shop(), config.permissions());
Map<String, Object> contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml()); Map<String, Object> contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml());
ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts()); ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts());
@@ -77,7 +77,8 @@ public record SoulStealConfig(
new ContractHudConfig( new ContractHudConfig(
YamlConfigHelper.bool(contractsSection, "hud_enabled", true), YamlConfigHelper.bool(contractsSection, "hud_enabled", true),
YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract") YamlConfigHelper.string(contractsSection, "hud_title", "Active Contract")
) ),
Math.max(0L, YamlConfigHelper.longValue(contractsSection, "default_repeat_cooldown_seconds", 0L))
); );
ShopUiConfig shopUiConfig = new ShopUiConfig( ShopUiConfig shopUiConfig = new ShopUiConfig(
@@ -109,6 +110,7 @@ public record SoulStealConfig(
YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"), YamlConfigHelper.string(permissionsSection, "reload_node", "soulsteal.admin.reload"),
YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"), YamlConfigHelper.string(permissionsSection, "shop_node", "soulsteal.shop"),
YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"), YamlConfigHelper.string(permissionsSection, "bounty_node", "soulsteal.bounty"),
YamlConfigHelper.bool(permissionsSection, "luckperms_enabled", true),
YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"), YamlConfigHelper.string(permissionsSection, "balance_others_node", "soulsteal.admin.balance.others"),
YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"), YamlConfigHelper.string(permissionsSection, "set_node", "soulsteal.admin.balance.set"),
YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"), YamlConfigHelper.string(permissionsSection, "add_node", "soulsteal.admin.balance.add"),
@@ -158,6 +160,7 @@ public record SoulStealConfig(
auto_claim: true auto_claim: true
hud_enabled: true hud_enabled: true
hud_title: "Active Contract" hud_title: "Active Contract"
default_repeat_cooldown_seconds: 0
shop: shop:
title: "Soul Shop" title: "Soul Shop"
@@ -186,6 +189,7 @@ public record SoulStealConfig(
reload_node: "soulsteal.admin.reload" reload_node: "soulsteal.admin.reload"
shop_node: "soulsteal.shop" shop_node: "soulsteal.shop"
bounty_node: "soulsteal.bounty" bounty_node: "soulsteal.bounty"
luckperms_enabled: true
balance_others_node: "soulsteal.admin.balance.others" balance_others_node: "soulsteal.admin.balance.others"
set_node: "soulsteal.admin.balance.set" set_node: "soulsteal.admin.balance.set"
add_node: "soulsteal.admin.balance.add" add_node: "soulsteal.admin.balance.add"
@@ -238,7 +242,7 @@ public record SoulStealConfig(
public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) { public record TrackerConfig(boolean enabled, long durationSeconds, int updateIntervalTicks, boolean expireIfTargetOffline) {
} }
public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud) { public record ContractConfig(boolean enabled, boolean autoClaim, ContractHudConfig hud, long defaultRepeatCooldownSeconds) {
} }
public record ContractHudConfig(boolean enabled, String title) { public record ContractHudConfig(boolean enabled, String title) {
@@ -271,6 +275,7 @@ public record SoulStealConfig(
String reloadNode, String reloadNode,
String shopNode, String shopNode,
String bountyNode, String bountyNode,
boolean luckpermsEnabled,
String balanceOthersNode, String balanceOthersNode,
String setNode, String setNode,
String addNode, String addNode,
@@ -19,13 +19,13 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
Object rawContracts = root.get("contracts"); Object rawContracts = root.get("contracts");
if (rawContracts instanceof List<?> rawList) { if (rawContracts instanceof List<?> rawList) {
for (Object rawContract : rawList) { for (Object rawContract : rawList) {
addContract(contracts, rawContract); addContract(contracts, rawContract, config);
} }
} else if (rawContracts instanceof Map<?, ?> rawSections) { } else if (rawContracts instanceof Map<?, ?> rawSections) {
for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) { for (Map.Entry<?, ?> sectionEntry : rawSections.entrySet()) {
if (sectionEntry.getValue() instanceof List<?> rawList) { if (sectionEntry.getValue() instanceof List<?> rawList) {
for (Object rawContract : rawList) { for (Object rawContract : rawList) {
addContract(contracts, rawContract); addContract(contracts, rawContract, config);
} }
} }
} }
@@ -49,12 +49,17 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
name: "Mining Contracts" name: "Mining Contracts"
icon: "minecraft:iron_pickaxe" icon: "minecraft:iron_pickaxe"
type: "mining" type: "mining"
target: "minecraft:iron_ore" targets:
- "minecraft:iron_ore"
- "minecraft:deepslate_iron_ore"
target_name: "Iron Ore" target_name: "Iron Ore"
description: "Mine iron ore to earn souls." amount: 20
amount: 64 reward: 200
reward: 250
repeatable: true repeatable: true
cooldown: 10
matches:
- "Iron Ore"
- "Deepslate Iron Ore"
hunting: hunting:
- id: "zombie_hunter" - id: "zombie_hunter"
name: "Zombie Hunter" name: "Zombie Hunter"
@@ -79,7 +84,7 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
return converted; return converted;
} }
private static void addContract(List<ContractDefinition> contracts, Object rawContract) { private static void addContract(List<ContractDefinition> contracts, Object rawContract, ContractConfig config) {
if (!(rawContract instanceof Map<?, ?> rawMap)) { if (!(rawContract instanceof Map<?, ?> rawMap)) {
return; return;
} }
@@ -97,17 +102,38 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
return; 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( contracts.add(new ContractDefinition(
id, id,
YamlConfigHelper.string(map, "name", id), YamlConfigHelper.string(map, "name", id),
YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"), YamlConfigHelper.string(map, "icon", type == ContractType.MINING ? "minecraft:iron_pickaxe" : "minecraft:zombie_head"),
type, type,
YamlConfigHelper.string(map, "target", ""), targets,
matches,
YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)), YamlConfigHelper.string(map, "target_name", YamlConfigHelper.string(map, "target", id)),
YamlConfigHelper.string(map, "description", ""), YamlConfigHelper.string(map, "description", ""),
Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)), Math.max(1L, YamlConfigHelper.longValue(map, "amount", 1L)),
Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)), Math.max(0L, YamlConfigHelper.longValue(map, "reward", 0L)),
YamlConfigHelper.bool(map, "repeatable", true) YamlConfigHelper.bool(map, "repeatable", true),
cooldown
)); ));
} }
} }
@@ -1,5 +1,8 @@
package com.g2806.soulsteal.contract; package com.g2806.soulsteal.contract;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* Immutable definition for one contract entry loaded from `catalog.yml`. * Immutable definition for one contract entry loaded from `catalog.yml`.
* *
@@ -11,11 +14,33 @@ public record ContractDefinition(
String name, String name,
String iconItemId, String iconItemId,
ContractType type, ContractType type,
String targetId, List<String> targetIds,
List<String> displayMatches,
String targetName, String targetName,
String description, String description,
long amountRequired, long amountRequired,
long reward, long reward,
boolean repeatable 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(", "));
}
} }
@@ -170,7 +170,13 @@ public final class ContractGuiService {
if (!contract.description().isBlank()) { if (!contract.description().isBlank()) {
lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY)); lore.add(Text.literal(contract.description()).formatted(Formatting.GRAY));
} }
lore.add(Text.literal("Target: " + contract.targetName()).formatted(Formatting.AQUA)); 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("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GOLD));
lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN)); lore.add(Text.literal("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY)); lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
@@ -237,7 +243,7 @@ public final class ContractGuiService {
default -> List.of(); default -> List.of();
}; };
return contracts.stream() return contracts.stream()
.filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id())) .filter(contract -> contractService.isContractAvailable(player.getUuid(), contract))
.toList(); .toList();
} }
@@ -19,12 +19,15 @@ public final class SoulStealData {
private Map<String, Set<String>> unlockedEntries = new HashMap<>(); private Map<String, Set<String>> unlockedEntries = new HashMap<>();
private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>(); private Map<String, Map<String, Long>> purchaseCooldowns = new HashMap<>();
private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>(); private Map<String, Map<String, Boolean>> grantedPermissions = new HashMap<>();
private Map<String, Integer> grantedRankPriorities = new HashMap<>();
private Map<String, Long> bountyPlacementCooldowns = new HashMap<>(); private Map<String, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>(); private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = new HashMap<>(); private Map<String, Boolean> scoreboardVisibility = new HashMap<>();
private Map<String, String> selectedContracts = new HashMap<>(); private Map<String, String> selectedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractProgress = new HashMap<>(); private Map<String, Map<String, Long>> contractProgress = new HashMap<>();
private Map<String, Set<String>> completedContracts = new HashMap<>(); private Map<String, Set<String>> completedContracts = new HashMap<>();
private Map<String, Map<String, Long>> contractCooldowns = new HashMap<>();
private Set<String> playerPlacedMiningTargets = new HashSet<>();
public SoulStealData normalize() { public SoulStealData normalize() {
if (souls == null) { if (souls == null) {
@@ -42,6 +45,9 @@ public final class SoulStealData {
if (grantedPermissions == null) { if (grantedPermissions == null) {
grantedPermissions = new HashMap<>(); grantedPermissions = new HashMap<>();
} }
if (grantedRankPriorities == null) {
grantedRankPriorities = new HashMap<>();
}
if (bountyPlacementCooldowns == null) { if (bountyPlacementCooldowns == null) {
bountyPlacementCooldowns = new HashMap<>(); bountyPlacementCooldowns = new HashMap<>();
} }
@@ -60,6 +66,12 @@ public final class SoulStealData {
if (completedContracts == null) { if (completedContracts == null) {
completedContracts = new HashMap<>(); completedContracts = new HashMap<>();
} }
if (contractCooldowns == null) {
contractCooldowns = new HashMap<>();
}
if (playerPlacedMiningTargets == null) {
playerPlacedMiningTargets = new HashSet<>();
}
return this; return this;
} }
@@ -108,6 +120,10 @@ public final class SoulStealData {
return grantedPermissions; return grantedPermissions;
} }
public Map<String, Integer> grantedRankPriorities() {
return grantedRankPriorities;
}
/** /**
* Returns the bounty placement cooldown table. * Returns the bounty placement cooldown table.
* *
@@ -146,4 +162,12 @@ public final class SoulStealData {
public Map<String, Set<String>> completedContracts() { public Map<String, Set<String>> completedContracts() {
return completedContracts; return completedContracts;
} }
public Map<String, Map<String, Long>> contractCooldowns() {
return contractCooldowns;
}
public Set<String> playerPlacedMiningTargets() {
return playerPlacedMiningTargets;
}
} }
@@ -6,11 +6,13 @@ import com.g2806.soulsteal.contract.ContractType;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.registry.Registries;
public final class ContractService { public final class ContractService {
private final Supplier<ContractCatalog> catalogSupplier; private final Supplier<ContractCatalog> catalogSupplier;
@@ -36,7 +38,7 @@ public final class ContractService {
if (contract.isEmpty()) { if (contract.isEmpty()) {
return false; return false;
} }
if (!contract.get().repeatable() && hasCompletedContract(player.getUuid(), contract.get().id())) { if (!isContractAvailable(player.getUuid(), contract.get())) {
return false; return false;
} }
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id()); dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
@@ -69,6 +71,21 @@ public final class ContractService {
.contains(contractId); .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) { public void recordMining(ServerPlayerEntity player, String blockId) {
record(player, ContractType.MINING, blockId); record(player, ContractType.MINING, blockId);
} }
@@ -77,13 +94,32 @@ public final class ContractService {
record(player, ContractType.HUNTING, 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) { private void record(ServerPlayerEntity player, ContractType type, String targetId) {
Optional<ContractDefinition> selected = selectedContract(player.getUuid()); Optional<ContractDefinition> selected = selectedContract(player.getUuid());
if (selected.isEmpty() || !catalogSupplier.get().enabled()) { if (selected.isEmpty() || !catalogSupplier.get().enabled()) {
return; return;
} }
ContractDefinition contract = selected.get(); ContractDefinition contract = selected.get();
if (contract.type() != type || !contract.targetId().equalsIgnoreCase(targetId)) { if (contract.type() != type) {
return;
}
boolean matches = contract.targetIds() != null && contract.targetIds().stream().anyMatch(t -> t.equalsIgnoreCase(targetId));
if (!matches) {
return; return;
} }
@@ -96,12 +132,22 @@ public final class ContractService {
player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+" player.sendMessage(net.minecraft.text.Text.literal("Contract complete: " + contract.name() + " (+"
+ contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false); + contract.reward() + " souls)").formatted(net.minecraft.util.Formatting.GREEN), false);
if (!contract.repeatable()) { if (!contract.repeatable()) {
// One-time contracts are permanently removed after completion.
dataStore.data().selectedContracts().remove(playerKey);
dataStore.data().completedContracts() dataStore.data().completedContracts()
.computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>()) .computeIfAbsent(playerKey, ignored -> new java.util.HashSet<>())
.add(contract.id()); .add(contract.id());
} } else {
if (!contract.repeatable()) { if (contract.cooldownSeconds() > 0L) {
dataStore.data().selectedContracts().remove(playerKey); // 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 { } else {
progressMap.put(contract.id(), updated); progressMap.put(contract.id(), updated);
@@ -5,6 +5,7 @@ import com.g2806.soulsteal.data.SoulStealDataStore;
import com.g2806.soulsteal.data.StoredBounty; import com.g2806.soulsteal.data.StoredBounty;
import com.g2806.soulsteal.service.ContractService; import com.g2806.soulsteal.service.ContractService;
import com.g2806.soulsteal.util.DurationFormatter; import com.g2806.soulsteal.util.DurationFormatter;
import com.g2806.soulsteal.util.HudTexts;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -31,6 +32,7 @@ import net.minecraft.scoreboard.ScoreboardObjective;
import net.minecraft.scoreboard.number.BlankNumberFormat; import net.minecraft.scoreboard.number.BlankNumberFormat;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.MutableText;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.Formatting; import net.minecraft.util.Formatting;
@@ -232,7 +234,16 @@ public final class HudService {
BossBar.Color.RED, BossBar.Color.RED,
BossBar.Style.PROGRESS BossBar.Style.PROGRESS
)); ));
bossBar.setName(Text.literal(configSupplier.get().hud().bountyBossbar().title() + ": " + totalValue + " souls | " + DurationFormatter.formatSeconds(remainingSeconds))); MutableText bossbarText = HudTexts.title(configSupplier.get().hud().bountyBossbar().title())
.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.setPercent(percent);
bossBar.setVisible(true); bossBar.setVisible(true);
if (!bossBar.getPlayers().contains(player)) { if (!bossBar.getPlayers().contains(player)) {
@@ -243,12 +254,12 @@ public final class HudService {
private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) { private List<Text> buildSidebarLines(ServerPlayerEntity player, long nowEpochMillis) {
List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid()); List<StoredBounty> activeBounties = bountyService.activeBountiesForTarget(player.getUuid());
List<Text> lines = new ArrayList<>(); List<Text> lines = new ArrayList<>();
lines.add(Text.literal("Souls: " + soulService.balanceOf(player.getUuid())).formatted(Formatting.GOLD)); lines.add(HudTexts.labeledValue("Souls", String.valueOf(soulService.balanceOf(player.getUuid())), Formatting.GOLD));
contractService.selectedContract(player.getUuid()).ifPresent(contract -> { contractService.selectedContract(player.getUuid()).ifPresent(contract -> {
long progress = contractService.progress(player.getUuid(), contract.id()); long progress = contractService.progress(player.getUuid(), contract.id());
lines.add(Text.literal("Contract: " + contract.name()).formatted(Formatting.AQUA)); lines.add(HudTexts.labeledValue("Contract", contract.name(), Formatting.WHITE));
lines.add(Text.literal("Progress: " + progress + "/" + contract.amountRequired()).formatted(Formatting.GRAY)); lines.add(HudTexts.labeledValue("Progress", progress + "/" + contract.amountRequired(), Formatting.GRAY));
}); });
if (!activeBounties.isEmpty()) { if (!activeBounties.isEmpty()) {
@@ -259,9 +270,9 @@ public final class HudService {
.orElse(nowEpochMillis); .orElse(nowEpochMillis);
remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L); remainingSeconds = Math.max(0L, (remainingSeconds - nowEpochMillis + 999L) / 1000L);
lines.add(Text.literal("Bounties: " + activeBounties.size()).formatted(Formatting.RED)); lines.add(HudTexts.labeledValue("Bounties", String.valueOf(activeBounties.size()), Formatting.RED));
lines.add(Text.literal("Wanted Value: " + totalValue).formatted(Formatting.GOLD)); lines.add(HudTexts.labeledValue("Wanted", String.valueOf(totalValue), Formatting.GOLD));
lines.add(Text.literal("Wanted Time: " + DurationFormatter.formatSeconds(remainingSeconds)).formatted(Formatting.DARK_RED)); lines.add(HudTexts.labeledValue("Time Left", DurationFormatter.formatSeconds(remainingSeconds), Formatting.DARK_RED));
} }
return lines; return lines;
@@ -294,7 +305,7 @@ public final class HudService {
return scoreboard.addObjective( return scoreboard.addObjective(
objectiveName, objectiveName,
ScoreboardCriterion.DUMMY, ScoreboardCriterion.DUMMY,
Text.literal(configSupplier.get().hud().scoreboard().title()).formatted(Formatting.DARK_AQUA), HudTexts.title(configSupplier.get().hud().scoreboard().title()),
ScoreboardCriterion.RenderType.INTEGER, ScoreboardCriterion.RenderType.INTEGER,
false, false,
BlankNumberFormat.INSTANCE BlankNumberFormat.INSTANCE
@@ -1,6 +1,7 @@
package com.g2806.soulsteal.service; package com.g2806.soulsteal.service;
import com.g2806.soulsteal.SoulStealMod; import com.g2806.soulsteal.SoulStealMod;
import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.data.SoulStealDataStore; import com.g2806.soulsteal.data.SoulStealDataStore;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
@@ -10,6 +11,7 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
@@ -21,9 +23,19 @@ import net.minecraft.server.network.ServerPlayerEntity;
*/ */
public final class PermissionService { public final class PermissionService {
private final SoulStealDataStore dataStore; private final SoulStealDataStore dataStore;
private final Supplier<PermissionConfig> permissionConfigSupplier;
public PermissionService(SoulStealDataStore dataStore) { public PermissionService(SoulStealDataStore dataStore, Supplier<PermissionConfig> permissionConfigSupplier) {
this.dataStore = dataStore; this.dataStore = dataStore;
this.permissionConfigSupplier = permissionConfigSupplier;
}
public SoulStealDataStore dataStore() {
return dataStore;
}
public boolean isLuckPermsEnabled() {
return permissionConfigSupplier.get().luckpermsEnabled();
} }
/** /**
@@ -136,6 +148,20 @@ public final class PermissionService {
"No supported permissions backend was available for that reward."); "No supported permissions backend was available for that reward.");
} }
public RankGrantResult grantLuckPermsGroup(UUID playerUuid, String group, boolean storeFallback) {
if (!isLuckPermsEnabled()) {
return new RankGrantResult(false, false, "LuckPerms is disabled in config.");
}
boolean granted = tryGrantLuckPermsGroup(playerUuid, group);
if (!granted) {
return new RankGrantResult(false, false, "No supported permissions backend was available for that rank.");
}
if (storeFallback) {
saveQuietly();
}
return new RankGrantResult(true, storeFallback, "Rank granted successfully.");
}
private boolean hasStoredPermission(UUID playerUuid, String permission) { private boolean hasStoredPermission(UUID playerUuid, String permission) {
return dataStore.data().grantedPermissions() return dataStore.data().grantedPermissions()
.getOrDefault(key(playerUuid), Map.of()) .getOrDefault(key(playerUuid), Map.of())
@@ -172,6 +198,43 @@ public final class PermissionService {
} }
} }
private boolean tryGrantLuckPermsGroup(UUID playerUuid, String group) {
try {
Class<?> providerClass = Class.forName("net.luckperms.api.LuckPermsProvider");
Object api = providerClass.getMethod("get").invoke(null);
Object userManager = api.getClass().getMethod("getUserManager").invoke(api);
Object groupManager = api.getClass().getMethod("getGroupManager").invoke(api);
Object lpGroup = groupManager.getClass().getMethod("getGroup", String.class).invoke(groupManager, group);
if (lpGroup == null) {
return false;
}
String groupName = String.valueOf(lpGroup.getClass().getMethod("getName").invoke(lpGroup));
Class<?> userClass = Class.forName("net.luckperms.api.model.user.User");
Consumer<Object> consumer = user -> {
try {
Object data = user.getClass().getMethod("data").invoke(user);
Class<?> nodeClass = Class.forName("net.luckperms.api.node.Node");
Class<?> inheritanceNodeClass = Class.forName("net.luckperms.api.node.types.InheritanceNode");
Object builder = inheritanceNodeClass.getMethod("builder", String.class).invoke(null, groupName);
Object builtNode = builder.getClass().getMethod("build").invoke(builder);
data.getClass().getMethod("add", nodeClass).invoke(data, builtNode);
userManager.getClass().getMethod("saveUser", userClass).invoke(userManager, user);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException exception) {
throw new RuntimeException(exception);
}
};
userManager.getClass().getMethod("modifyUser", UUID.class, Consumer.class).invoke(userManager, playerUuid, consumer);
return true;
} catch (ClassNotFoundException exception) {
return false;
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException exception) {
SoulStealMod.LOGGER.warn("Failed to grant LuckPerms group {} to {}", group, playerUuid, exception);
return false;
}
}
private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) { private Boolean invokePermissionsCheck(Object subject, String permission, Object defaultValue) {
try { try {
Class<?> permissionsClass = Class.forName("me.lucko.fabric.api.permissions.v0.Permissions"); Class<?> permissionsClass = Class.forName("me.lucko.fabric.api.permissions.v0.Permissions");
@@ -235,4 +298,7 @@ public final class PermissionService {
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) {
}
} }
@@ -6,8 +6,12 @@ import com.g2806.soulsteal.shop.ItemRewardDefinition;
import com.g2806.soulsteal.shop.PermissionRewardDefinition; import com.g2806.soulsteal.shop.PermissionRewardDefinition;
import com.g2806.soulsteal.shop.RewardDefinition; import com.g2806.soulsteal.shop.RewardDefinition;
import com.g2806.soulsteal.shop.StackMode; import com.g2806.soulsteal.shop.StackMode;
import com.g2806.soulsteal.shop.RankRewardDefinition;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import net.minecraft.component.DataComponentTypes; import net.minecraft.component.DataComponentTypes;
import net.minecraft.component.type.LoreComponent; import net.minecraft.component.type.LoreComponent;
@@ -55,6 +59,11 @@ public final class RewardService {
return new ValidationResult(false, "Permission rewards require a non-empty node."); 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 -> { case CommandRewardDefinition commandReward -> {
if (commandReward.command().isBlank()) { if (commandReward.command().isBlank()) {
return new ValidationResult(false, "Command rewards require a non-empty command string."); return new ValidationResult(false, "Command rewards require a non-empty command string.");
@@ -95,6 +104,13 @@ public final class RewardService {
} }
granted.add(rewardDisplayName(permissionReward)); 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 -> { case CommandRewardDefinition commandReward -> {
executeCommandReward(player, commandReward); executeCommandReward(player, commandReward);
granted.add(rewardDisplayName(commandReward)); granted.add(rewardDisplayName(commandReward));
@@ -112,6 +128,7 @@ public final class RewardService {
case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY)); case ItemRewardDefinition itemReward -> lines.add(Text.literal("Reward: " + itemReward.amount() + "x " + rewardDisplayName(itemReward)).formatted(Formatting.GRAY));
case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY)); case EffectRewardDefinition effectReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(effectReward) + " for " + effectReward.durationSeconds() + "s").formatted(Formatting.GRAY));
case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY)); case PermissionRewardDefinition permissionReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(permissionReward)).formatted(Formatting.GRAY));
case 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)); case CommandRewardDefinition commandReward -> lines.add(Text.literal("Reward: " + rewardDisplayName(commandReward)).formatted(Formatting.GRAY));
} }
} }
@@ -222,6 +239,13 @@ public final class RewardService {
return "permission " + reward.node(); return "permission " + reward.node();
} }
private String rewardDisplayName(RankRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName();
}
return "rank command";
}
private String rewardDisplayName(CommandRewardDefinition reward) { private String rewardDisplayName(CommandRewardDefinition reward) {
if (reward.displayName() != null && !reward.displayName().isBlank()) { if (reward.displayName() != null && !reward.displayName().isBlank()) {
return reward.displayName(); return reward.displayName();
@@ -253,6 +277,26 @@ public final class RewardService {
return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null); return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null);
} }
private GrantResult grantRankReward(ServerPlayerEntity player, RankRewardDefinition reward) {
String playerKey = player.getUuidAsString();
int currentPriority = permissionService.dataStore().data().grantedRankPriorities().getOrDefault(playerKey, Integer.MIN_VALUE);
if (currentPriority >= reward.priority()) {
return new GrantResult(false, "You already own an equal or higher rank.", List.of());
}
permissionService.dataStore().data().grantedRankPriorities().put(playerKey, reward.priority());
try {
permissionService.dataStore().save();
} catch (IOException exception) {
throw new UncheckedIOException("Failed to persist rank reward data.", exception);
}
executeCommandReward(player, new CommandRewardDefinition(
reward.command(),
reward.runAsConsole(),
reward.displayName()
));
return new GrantResult(true, "Rank command executed successfully.", List.of(reward.command()));
}
public record ValidationResult(boolean success, String message) { public record ValidationResult(boolean success, String message) {
} }
@@ -0,0 +1,9 @@
package com.g2806.soulsteal.shop;
/** Rank reward backed by a command and a track priority. */
public record RankRewardDefinition(String command, int priority, boolean runAsConsole, String displayName) implements RewardDefinition {
@Override
public RewardType type() {
return RewardType.RANK;
}
}
@@ -1,6 +1,6 @@
package com.g2806.soulsteal.shop; package com.g2806.soulsteal.shop;
/** Marker interface for all shop reward definitions. */ /** Marker interface for all shop reward definitions. */
public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition { public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition, RankRewardDefinition {
RewardType type(); RewardType type();
} }
@@ -5,5 +5,6 @@ public enum RewardType {
ITEM, ITEM,
PERMISSION, PERMISSION,
EFFECT, EFFECT,
COMMAND COMMAND,
RANK
} }
@@ -1,5 +1,6 @@
package com.g2806.soulsteal.shop; package com.g2806.soulsteal.shop;
import com.g2806.soulsteal.config.SoulStealConfig.PermissionConfig;
import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig; import com.g2806.soulsteal.config.SoulStealConfig.ShopUiConfig;
import com.g2806.soulsteal.config.YamlConfigHelper; import com.g2806.soulsteal.config.YamlConfigHelper;
import java.util.ArrayList; import java.util.ArrayList;
@@ -11,7 +12,7 @@ import java.util.Optional;
/** Parsed representation of the editable shop catalog. */ /** Parsed representation of the editable shop catalog. */
public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) { public record ShopCatalog(String title, int rows, String fillerItemId, List<ShopCategoryDefinition> categories) {
public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi) { public static ShopCatalog fromMap(Map<String, Object> root, ShopUiConfig shopUi, PermissionConfig permissionConfig) {
Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories"); Map<String, Object> categoriesSection = YamlConfigHelper.section(root, "categories");
List<ShopCategoryDefinition> categories = new ArrayList<>(); List<ShopCategoryDefinition> categories = new ArrayList<>();
@@ -30,7 +31,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
} }
Map<String, Object> itemMap = toStringMap(rawItemMap); Map<String, Object> itemMap = toStringMap(rawItemMap);
List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards")); List<RewardDefinition> rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards"), permissionConfig.luckpermsEnabled());
if (rewards.isEmpty()) { if (rewards.isEmpty()) {
continue; continue;
} }
@@ -131,6 +132,22 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
store_fallback: true store_fallback: true
name: "Nickname Permission" 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: utility_commands:
name: "Command Hooks" name: "Command Hooks"
icon: "minecraft:command_block" icon: "minecraft:command_block"
@@ -152,7 +169,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
"""; """;
} }
private static List<RewardDefinition> parseRewards(List<Object> rawRewards) { private static List<RewardDefinition> parseRewards(List<Object> rawRewards, boolean luckPermsEnabled) {
List<RewardDefinition> rewards = new ArrayList<>(); List<RewardDefinition> rewards = new ArrayList<>();
for (Object rewardValue : rawRewards) { for (Object rewardValue : rawRewards) {
@@ -182,6 +199,17 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List<Shop
YamlConfigHelper.bool(rewardMap, "store_fallback", true), YamlConfigHelper.bool(rewardMap, "store_fallback", true),
rewardName 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 -> { case EFFECT -> {
StackMode stackMode; StackMode stackMode;
try { try {
@@ -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);
}
}
@@ -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));
}
}