diff --git a/.gitignore b/.gitignore index fbfbcca..3a4e3c6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .gradle/ build/ out/ +logs/ # Loom / Mod Dev Gradle caches .loom-cache/ diff --git a/build.gradle b/build.gradle index 697ded2..aa9b5ba 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/SoulStealMod.java b/src/main/java/com/g2806/soulsteal/SoulStealMod.java index 3f30a11..3218636 100644 --- a/src/main/java/com/g2806/soulsteal/SoulStealMod.java +++ b/src/main/java/com/g2806/soulsteal/SoulStealMod.java @@ -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. + * + *

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.

+ */ + static BlockPos placedBlockPos(BlockPos clickedPos, Direction side) { + return clickedPos.offset(side); + } + /** * Returns the loaded configuration bundle. * diff --git a/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java index f6d78b4..2f0a0de 100644 --- a/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java +++ b/src/main/java/com/g2806/soulsteal/config/ConfigBundle.java @@ -16,7 +16,7 @@ public record ConfigBundle(SoulStealConfig config, ShopCatalog shopCatalog, Cont SoulStealConfig config = SoulStealConfig.fromMap(configMap); Map 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 contractMap = YamlConfigHelper.loadOrCreate(configDirectory.resolve("catalog.yml"), ContractCatalog.defaultYaml()); ContractCatalog contractCatalog = ContractCatalog.fromMap(contractMap, config.contracts()); diff --git a/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java index 37d8c81..d733bbf 100644 --- a/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java +++ b/src/main/java/com/g2806/soulsteal/config/SoulStealConfig.java @@ -12,8 +12,8 @@ public record SoulStealConfig( ContractConfig contracts, ShopUiConfig shop, HudConfig hud, - PermissionConfig permissions -) { + PermissionConfig permissions +) { public static SoulStealConfig fromMap(Map root) { Map economySection = YamlConfigHelper.section(root, "economy"); Map 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 ) { diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java index c179f36..f2dac00 100644 --- a/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java +++ b/src/main/java/com/g2806/soulsteal/contract/ContractCatalog.java @@ -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 contracts, Object rawContract) { + private static void addContract(List 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 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 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 )); } } diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java b/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java index 26d1d5d..a275e7b 100644 --- a/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java +++ b/src/main/java/com/g2806/soulsteal/contract/ContractDefinition.java @@ -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 targetIds, + List 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(", ")); + } } diff --git a/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java index 2b11ea5..11f8839 100644 --- a/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java +++ b/src/main/java/com/g2806/soulsteal/contract/ContractGuiService.java @@ -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(); } diff --git a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java index c6d1171..04e249d 100644 --- a/src/main/java/com/g2806/soulsteal/data/SoulStealData.java +++ b/src/main/java/com/g2806/soulsteal/data/SoulStealData.java @@ -18,13 +18,16 @@ public final class SoulStealData { private List activeBounties = new ArrayList<>(); private Map> unlockedEntries = new HashMap<>(); private Map> purchaseCooldowns = new HashMap<>(); - private Map> grantedPermissions = new HashMap<>(); - private Map bountyPlacementCooldowns = new HashMap<>(); - private Map playerNames = new HashMap<>(); - private Map scoreboardVisibility = new HashMap<>(); + private Map> grantedPermissions = new HashMap<>(); + private Map grantedRankPriorities = new HashMap<>(); + private Map bountyPlacementCooldowns = new HashMap<>(); + private Map playerNames = new HashMap<>(); + private Map scoreboardVisibility = new HashMap<>(); private Map selectedContracts = new HashMap<>(); private Map> contractProgress = new HashMap<>(); private Map> completedContracts = new HashMap<>(); + private Map> contractCooldowns = new HashMap<>(); + private Set 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> grantedPermissions() { - return grantedPermissions; + return grantedPermissions; + } + + public Map grantedRankPriorities() { + return grantedRankPriorities; } /** @@ -143,7 +159,15 @@ public final class SoulStealData { return contractProgress; } - public Map> completedContracts() { - return completedContracts; + public Map> completedContracts() { + return completedContracts; + } + + public Map> contractCooldowns() { + return contractCooldowns; + } + + public Set playerPlacedMiningTargets() { + return playerPlacedMiningTargets; } } diff --git a/src/main/java/com/g2806/soulsteal/service/ContractService.java b/src/main/java/com/g2806/soulsteal/service/ContractService.java index cf06eeb..3c5cdd1 100644 --- a/src/main/java/com/g2806/soulsteal/service/ContractService.java +++ b/src/main/java/com/g2806/soulsteal/service/ContractService.java @@ -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 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 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); diff --git a/src/main/java/com/g2806/soulsteal/service/PermissionService.java b/src/main/java/com/g2806/soulsteal/service/PermissionService.java index 5f3679f..d932d62 100644 --- a/src/main/java/com/g2806/soulsteal/service/PermissionService.java +++ b/src/main/java/com/g2806/soulsteal/service/PermissionService.java @@ -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; *

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.

*/ -public final class PermissionService { - private final SoulStealDataStore dataStore; - - public PermissionService(SoulStealDataStore dataStore) { +public final class PermissionService { + private final SoulStealDataStore dataStore; + private final Supplier permissionConfigSupplier; + + public PermissionService(SoulStealDataStore dataStore, Supplier 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 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) { + } } diff --git a/src/main/java/com/g2806/soulsteal/service/RewardService.java b/src/main/java/com/g2806/soulsteal/service/RewardService.java index 23fef91..88345dd 100644 --- a/src/main/java/com/g2806/soulsteal/service/RewardService.java +++ b/src/main/java/com/g2806/soulsteal/service/RewardService.java @@ -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 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 resolveStatusEffect(String effectId) { - Identifier identifier = Identifier.tryParse(effectId); - if (identifier == null) { - return null; - } - return Registries.STATUS_EFFECT.getEntry(identifier).orElse(null); - } + private RegistryEntry 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 grantedRewards) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/g2806/soulsteal/shop/RankRewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/RankRewardDefinition.java new file mode 100644 index 0000000..a9d8a3b --- /dev/null +++ b/src/main/java/com/g2806/soulsteal/shop/RankRewardDefinition.java @@ -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; + } +} diff --git a/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java b/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java index 4f40079..88c14b0 100644 --- a/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java +++ b/src/main/java/com/g2806/soulsteal/shop/RewardDefinition.java @@ -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(); -} \ No newline at end of file +public sealed interface RewardDefinition permits CommandRewardDefinition, EffectRewardDefinition, ItemRewardDefinition, PermissionRewardDefinition, RankRewardDefinition { + RewardType type(); +} diff --git a/src/main/java/com/g2806/soulsteal/shop/RewardType.java b/src/main/java/com/g2806/soulsteal/shop/RewardType.java index 14281e9..4a50964 100644 --- a/src/main/java/com/g2806/soulsteal/shop/RewardType.java +++ b/src/main/java/com/g2806/soulsteal/shop/RewardType.java @@ -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 -} \ No newline at end of file +public enum RewardType { + ITEM, + PERMISSION, + EFFECT, + COMMAND, + RANK +} diff --git a/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java b/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java index 3586b34..3460b8c 100644 --- a/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java +++ b/src/main/java/com/g2806/soulsteal/shop/ShopCatalog.java @@ -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 categories) { - public static ShopCatalog fromMap(Map root, ShopUiConfig shopUi) { +public record ShopCatalog(String title, int rows, String fillerItemId, List categories) { + public static ShopCatalog fromMap(Map root, ShopUiConfig shopUi, PermissionConfig permissionConfig) { Map categoriesSection = YamlConfigHelper.section(root, "categories"); List categories = new ArrayList<>(); @@ -30,7 +31,7 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List itemMap = toStringMap(rawItemMap); - List rewards = parseRewards(YamlConfigHelper.list(itemMap, "rewards")); + List 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 category(String key) { @@ -111,11 +112,11 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List parseRewards(List rawRewards) { + private static List parseRewards(List rawRewards, boolean luckPermsEnabled) { List rewards = new ArrayList<>(); for (Object rewardValue : rawRewards) { @@ -176,16 +193,27 @@ public record ShopCatalog(String title, int rows, String fillerItemId, List 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 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()); + } +} diff --git a/src/test/java/com/g2806/soulsteal/service/ContractServiceTest.java b/src/test/java/com/g2806/soulsteal/service/ContractServiceTest.java new file mode 100644 index 0000000..7c659f1 --- /dev/null +++ b/src/test/java/com/g2806/soulsteal/service/ContractServiceTest.java @@ -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 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 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 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); + } +} diff --git a/src/test/java/com/g2806/soulsteal/shop/ShopCatalogTest.java b/src/test/java/com/g2806/soulsteal/shop/ShopCatalogTest.java new file mode 100644 index 0000000..94df122 --- /dev/null +++ b/src/test/java/com/g2806/soulsteal/shop/ShopCatalogTest.java @@ -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 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 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()); + } +} diff --git a/src/test/java/com/g2806/soulsteal/util/DurationFormatterTest.java b/src/test/java/com/g2806/soulsteal/util/DurationFormatterTest.java new file mode 100644 index 0000000..3c9b951 --- /dev/null +++ b/src/test/java/com/g2806/soulsteal/util/DurationFormatterTest.java @@ -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)); + } +}