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