feat: add contract cooldowns, mining target matches, and rank command rewards

This commit is contained in:
darwincereska
2026-05-10 15:44:34 -04:00
parent 7f3bb68719
commit dce135c857
21 changed files with 795 additions and 202 deletions
+1
View File
@@ -2,6 +2,7 @@
.gradle/
build/
out/
logs/
# Loom / Mod Dev Gradle caches
.loom-cache/
+21 -13
View File
@@ -20,15 +20,19 @@ repositories {
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}"
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "org.yaml:snakeyaml:${project.snakeyaml_version}"
include "org.yaml:snakeyaml:${project.snakeyaml_version}"
testImplementation platform("org.junit:junit-bom:5.11.4")
testImplementation "org.junit.jupiter:junit-jupiter"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
processResources {
inputs.property 'version', project.version
@@ -38,9 +42,13 @@ processResources {
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
test {
useJUnitPlatform()
}
java {
withSourcesJar()
@@ -65,4 +73,4 @@ publishing {
repositories {
}
}
}
@@ -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.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 net.minecraft.text.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -56,7 +61,6 @@ public final class SoulStealMod implements ModInitializer {
private TrackerCompassService trackerCompassService;
private ShopService shopService;
private HudService hudService;
/**
* Initializes the mod, loads configuration and persistent state, and registers all runtime
* 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);
}
permissionService = new PermissionService(dataStore);
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);
@@ -102,8 +106,36 @@ public final class SoulStealMod implements ModInitializer {
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());
}
});
@@ -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.
*
@@ -16,7 +16,7 @@ public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog, Cont
SoulStealConfig config = SoulStealConfig.fromMap(configMap);
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());
ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts());
@@ -12,8 +12,8 @@ public record SoulStealConfig(
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");
@@ -77,7 +77,8 @@ public record SoulStealConfig(
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(
@@ -104,16 +105,17 @@ 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")
);
@@ -153,17 +155,18 @@ public record SoulStealConfig(
update_interval_ticks: 20
expire_if_target_offline: false
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
contracts:
enabled: true
auto_claim: true
hud_enabled: true
hud_title: "Active Contract"
default_repeat_cooldown_seconds: 0
shop:
title: "Soul Shop"
rows: 3
filler_item: "minecraft:light_gray_stained_glass_pane"
default_purchase_cooldown_seconds: 0
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
@@ -184,9 +187,10 @@ public record SoulStealConfig(
# 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"
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"
@@ -238,7 +242,7 @@ public record SoulStealConfig(
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) {
@@ -266,15 +270,16 @@ 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
) {
@@ -19,13 +19,13 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
Object rawContracts = root.get("contracts");
if (rawContracts instanceof List<?> rawList) {
for (Object rawContract : rawList) {
addContract(contracts, rawContract);
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);
addContract(contracts, rawContract, config);
}
}
}
@@ -49,12 +49,17 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
name: "Mining Contracts"
icon: "minecraft:iron_pickaxe"
type: "mining"
target: "minecraft:iron_ore"
targets:
- "minecraft:iron_ore"
- "minecraft:deepslate_iron_ore"
target_name: "Iron Ore"
description: "Mine iron ore to earn souls."
amount: 64
reward: 250
amount: 20
reward: 200
repeatable: true
cooldown: 10
matches:
- "Iron Ore"
- "Deepslate Iron Ore"
hunting:
- id: "zombie_hunter"
name: "Zombie Hunter"
@@ -79,7 +84,7 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
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)) {
return;
}
@@ -97,17 +102,38 @@ public record ContractCatalog(boolean enabled, boolean autoClaim, boolean hudEna
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,
YamlConfigHelper.string(map, "target", ""),
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)
YamlConfigHelper.bool(map, "repeatable", true),
cooldown
));
}
}
@@ -1,5 +1,8 @@
package com.g2806.soulsteal.contract;
import java.util.List;
import java.util.stream.Collectors;
/**
* Immutable definition for one contract entry loaded from `catalog.yml`.
*
@@ -11,11 +14,33 @@ public record ContractDefinition(
String name,
String iconItemId,
ContractType type,
String targetId,
List<String> targetIds,
List<String> displayMatches,
String targetName,
String description,
long amountRequired,
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()) {
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("Reward: " + contract.reward() + " souls").formatted(Formatting.GREEN));
lore.add(Text.literal(contract.repeatable() ? "Repeatable" : "One-time").formatted(Formatting.DARK_GRAY));
@@ -237,7 +243,7 @@ public final class ContractGuiService {
default -> List.of();
};
return contracts.stream()
.filter(contract -> contract.repeatable() || !contractService.hasCompletedContract(player.getUuid(), contract.id()))
.filter(contract -> contractService.isContractAvailable(player.getUuid(), contract))
.toList();
}
@@ -18,13 +18,16 @@ 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, Long> bountyPlacementCooldowns = new HashMap<>();
private Map<String, String> playerNames = new HashMap<>();
private Map<String, Boolean> scoreboardVisibility = 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() {
if (souls == null) {
@@ -39,9 +42,12 @@ 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<>();
}
@@ -57,8 +63,14 @@ public final class SoulStealData {
if (contractProgress == null) {
contractProgress = new HashMap<>();
}
if (completedContracts == null) {
completedContracts = new HashMap<>();
if (completedContracts == null) {
completedContracts = new HashMap<>();
}
if (contractCooldowns == null) {
contractCooldowns = new HashMap<>();
}
if (playerPlacedMiningTargets == null) {
playerPlacedMiningTargets = new HashSet<>();
}
return this;
}
@@ -105,7 +117,11 @@ public final class SoulStealData {
* @return mutable permission map
*/
public Map<String, Map<String, Boolean>> grantedPermissions() {
return grantedPermissions;
return grantedPermissions;
}
public Map<String, Integer> grantedRankPriorities() {
return grantedRankPriorities;
}
/**
@@ -143,7 +159,15 @@ public final class SoulStealData {
return contractProgress;
}
public Map<String, Set<String>> completedContracts() {
return completedContracts;
public Map<String, Set<String>> 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 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;
@@ -36,7 +38,7 @@ public final class ContractService {
if (contract.isEmpty()) {
return false;
}
if (!contract.get().repeatable() && hasCompletedContract(player.getUuid(), contract.get().id())) {
if (!isContractAvailable(player.getUuid(), contract.get())) {
return false;
}
dataStore.data().selectedContracts().put(key(player.getUuid()), contract.get().id());
@@ -69,6 +71,21 @@ public final class ContractService {
.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);
}
@@ -77,13 +94,32 @@ public final class ContractService {
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 || !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;
}
@@ -96,12 +132,22 @@ public final class ContractService {
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());
}
if (!contract.repeatable()) {
dataStore.data().selectedContracts().remove(playerKey);
} 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);
@@ -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,11 +21,21 @@ 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) {
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();
}
/**
@@ -112,7 +124,7 @@ public final class PermissionService {
* @return grant outcome and backend details
*/
public GrantResult grantPersistentPermission(UUID playerUuid, String permission, boolean value, boolean storeFallback) {
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean grantedViaLuckPerms = tryGrantWithLuckPerms(playerUuid, permission, value);
boolean storedInternally = false;
if (storeFallback) {
@@ -132,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()
@@ -142,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);
@@ -168,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 {
@@ -233,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) {
}
}
}
@@ -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;
}
}
}
@@ -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));
}
}