extend ability system to support dynamic slot counts, add skill tree validation, new prerequisites API, and extended mod commands

This commit is contained in:
2026-05-25 12:33:49 +02:00
parent f7332054ea
commit 77fbada438
32 changed files with 961 additions and 128 deletions
@@ -1,5 +1,6 @@
package net.fretux.skillengine.skilltree;
import net.fretux.skillengine.api.AbilityHandlerRegistry;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
@@ -48,6 +49,8 @@ public class AbilityNode {
public int getCooldown() { return cooldown; }
public void execute(ServerPlayer player) {
player.sendSystemMessage(Component.literal("Activated ability: " + id));
if (!AbilityHandlerRegistry.execute(player, this)) {
player.sendSystemMessage(Component.literal("Activated ability: " + id));
}
}
}
@@ -8,16 +8,28 @@ import java.util.Map;
public class AbilityNodeRegistry {
private static final Map<ResourceLocation, AbilityNode> ABILITIES = new LinkedHashMap<>();
private static final Map<ResourceLocation, Integer> DUPLICATE_COUNTS = new LinkedHashMap<>();
public static void clear() {
ABILITIES.clear();
DUPLICATE_COUNTS.clear();
}
public static void put(AbilityNode node) {
ABILITIES.put(node.getId(), node);
if (ABILITIES.put(node.getId(), node) != null) {
DUPLICATE_COUNTS.merge(node.getId(), 1, Integer::sum);
}
}
public static AbilityNode get(ResourceLocation id) {
return ABILITIES.get(id);
}
public static Collection<AbilityNode> all() {
return ABILITIES.values();
}
public static Map<ResourceLocation, Integer> duplicateCounts() {
return Map.copyOf(DUPLICATE_COUNTS);
}
}
@@ -1,8 +1,9 @@
package net.fretux.skillengine.skilltree;
import net.fretux.ascend.player.PlayerStatsProvider;
import net.fretux.skillengine.api.PrerequisiteProviderRegistry;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import java.util.ArrayList;
@@ -43,13 +44,13 @@ public class SkillLogic {
public static boolean meetsAscendPrerequisites(Player player, SkillNode node) {
if (node.getPrereqAttributes().isEmpty()) return true;
return player.getCapability(PlayerStatsProvider.PLAYER_STATS).map(stats -> {
for (Map.Entry<String, Integer> req : node.getPrereqAttributes().entrySet()) {
int current = stats.getAttributeLevel(req.getKey());
if (current < req.getValue()) return false;
if (!(player instanceof ServerPlayer serverPlayer)) return true;
for (Map.Entry<String, Integer> req : node.getPrereqAttributes().entrySet()) {
if (!PrerequisiteProviderRegistry.meets(serverPlayer, node, req.getKey(), req.getValue())) {
return false;
}
return true;
}).orElse(false);
}
return true;
}
private static boolean collectUnlockPlan(PlayerSkillData data,
@@ -16,6 +16,9 @@ public class SkillNode {
private final float y;
private final List<ResourceLocation> links;
private final List<ResourceLocation> tags;
private final ResourceLocation tree;
private final String category;
private final String layer;
private final ResourceLocation icons;
private final Map<String, Integer> prereqAttributes;
private final List<ResourceLocation> exclusiveWith;
@@ -27,6 +30,9 @@ public class SkillNode {
float x, float y,
List<ResourceLocation> links,
List<ResourceLocation> tags,
ResourceLocation tree,
String category,
String layer,
ResourceLocation icons,
Map<String, Integer> prereqAttributes,
List<ResourceLocation> exclusiveWith){
@@ -39,6 +45,9 @@ public class SkillNode {
this.y = y;
this.links = List.copyOf(links);
this.tags = List.copyOf(tags);
this.tree = tree;
this.category = category;
this.layer = layer;
this.icons = icons;
this.prereqAttributes = Map.copyOf(prereqAttributes);
this.exclusiveWith = List.copyOf(exclusiveWith);
@@ -76,6 +85,18 @@ public class SkillNode {
return tags;
}
public ResourceLocation getTree() {
return tree;
}
public String getCategory() {
return category;
}
public String getLayer() {
return layer;
}
public ResourceLocation getIcons() {
return icons;
}
@@ -91,4 +112,4 @@ public class SkillNode {
public boolean isRoot() {
return links.isEmpty();
}
}
}
@@ -14,14 +14,18 @@ import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
private static final Gson GSON = new GsonBuilder().create();
private static final ResourceLocation DEFAULT_TREE =
ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, "main");
private static final Pattern TRANSLATION_KEY =
Pattern.compile("[a-z0-9_.-]+\\.[a-z0-9_.-]+");
public SkillNodeLoader() {
super(GSON, "skillnodes");
@@ -37,15 +41,14 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
@NotNull ResourceManager resourceManager,
@NotNull ProfilerFiller profiler) {
SkillEngine.LOGGER.info("Loading skill nodes...");
validateSingleSkillTreeProvider(jsons);
SkillNodeRegistry.clear();
jsons.forEach((id, element) -> {
JsonObject obj = element.getAsJsonObject();
ResourceLocation nodeId = obj.has("id")
? ResourceLocation.parse(obj.get("id").getAsString())
: id;
String title = obj.get("title").getAsString();
String description = obj.get("description").getAsString();
Component title = readComponent(obj, "title");
Component description = readComponent(obj, "description");
int cost = obj.get("cost").getAsInt();
JsonObject pos = obj.getAsJsonObject("position");
float x = pos.get("x").getAsFloat();
@@ -54,8 +57,15 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
obj.getAsJsonArray("links").forEach(e ->
links.add(ResourceLocation.parse(e.getAsString())));
List<ResourceLocation> tags = new ArrayList<>();
obj.getAsJsonArray("tags").forEach(e ->
tags.add(ResourceLocation.parse(e.getAsString())));
if (obj.has("tags")) {
obj.getAsJsonArray("tags").forEach(e ->
tags.add(ResourceLocation.parse(e.getAsString())));
}
ResourceLocation tree = obj.has("tree")
? ResourceLocation.parse(obj.get("tree").getAsString())
: ResourceLocation.fromNamespaceAndPath(nodeId.getNamespace(), DEFAULT_TREE.getPath());
String category = obj.has("category") ? obj.get("category").getAsString() : "";
String layer = obj.has("layer") ? obj.get("layer").getAsString() : "";
ResourceLocation icons = ResourceLocation.parse(obj.get("icons").getAsString());
Map<String, Integer> prereqAttributes = new HashMap<>();
if (obj.has("prerequisites")) {
@@ -71,12 +81,15 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
}
SkillNode node = new SkillNode(
nodeId,
Component.literal(title),
Component.literal(description),
title,
description,
cost,
x, y,
links,
tags,
tree,
category,
layer,
icons,
prereqAttributes,
exclusiveWith
@@ -87,28 +100,23 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
SkillEngine.LOGGER.info("Loaded {} skill nodes", SkillNodeRegistry.all().size());
}
private static void validateSingleSkillTreeProvider(Map<ResourceLocation, JsonElement> jsons) {
Map<String, List<ResourceLocation>> providers = new LinkedHashMap<>();
jsons.keySet().stream()
.filter(id -> !SkillEngine.MODID.equals(id.getNamespace()))
.forEach(id -> providers.computeIfAbsent(id.getNamespace(), ignored -> new ArrayList<>()).add(id));
if (providers.size() <= 1) {
return;
private static Component readComponent(JsonObject obj, String key) {
if (!obj.has(key)) {
return Component.empty();
}
StringBuilder message = new StringBuilder();
message.append("Skill Engine detected multiple mods adding skill tree nodes: ");
providers.forEach((namespace, nodeIds) ->
message.append(namespace)
.append(" (")
.append(nodeIds.size())
.append(" nodes), "));
message.setLength(message.length() - 2);
message.append(". Skill Engine supports one skill tree provider at a time because multiple skill trees overlap in the same UI. Remove all but one skill tree addon.");
String error = message.toString();
SkillEngine.LOGGER.error(error);
throw new IllegalStateException(error);
JsonElement element = obj.get(key);
if (element.isJsonObject()) {
JsonObject text = element.getAsJsonObject();
if (text.has("translate")) {
return Component.translatable(text.get("translate").getAsString());
}
if (text.has("text")) {
return Component.literal(text.get("text").getAsString());
}
}
String value = element.getAsString();
return TRANSLATION_KEY.matcher(value).matches()
? Component.translatable(value)
: Component.literal(value);
}
}
@@ -3,19 +3,36 @@ package net.fretux.skillengine.skilltree;
import net.minecraft.resources.ResourceLocation;
import java.util.Collection;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
public class SkillNodeRegistry {
private static final Map<ResourceLocation, SkillNode> NODES = new LinkedHashMap<>();
private static final Map<ResourceLocation, Map<ResourceLocation, SkillNode>> NODES_BY_TREE = new LinkedHashMap<>();
private static final Map<ResourceLocation, Integer> DUPLICATE_COUNTS = new LinkedHashMap<>();
public static void clear() {
NODES.clear();
NODES_BY_TREE.clear();
DUPLICATE_COUNTS.clear();
}
public static void put(SkillNode node) {
NODES.put(node.getId(), node);
SkillNode previous = NODES.put(node.getId(), node);
if (previous != null) {
DUPLICATE_COUNTS.merge(node.getId(), 1, Integer::sum);
Map<ResourceLocation, SkillNode> previousTree = NODES_BY_TREE.get(previous.getTree());
if (previousTree != null) {
previousTree.remove(previous.getId());
if (previousTree.isEmpty()) {
NODES_BY_TREE.remove(previous.getTree());
}
}
}
NODES_BY_TREE.computeIfAbsent(node.getTree(), ignored -> new LinkedHashMap<>())
.put(node.getId(), node);
}
public static SkillNode get(ResourceLocation id) {
@@ -25,4 +42,17 @@ public class SkillNodeRegistry {
public static Collection<SkillNode> all() {
return NODES.values();
}
public static List<ResourceLocation> trees() {
return List.copyOf(NODES_BY_TREE.keySet());
}
public static Collection<SkillNode> byTree(ResourceLocation tree) {
Map<ResourceLocation, SkillNode> nodes = NODES_BY_TREE.get(tree);
return nodes != null ? nodes.values() : List.of();
}
public static Map<ResourceLocation, Integer> duplicateCounts() {
return Map.copyOf(DUPLICATE_COUNTS);
}
}
@@ -0,0 +1,172 @@
package net.fretux.skillengine.skilltree;
import net.fretux.skillengine.api.PrerequisiteProviderRegistry;
import net.minecraft.resources.ResourceLocation;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class SkillTreeValidator {
private SkillTreeValidator() {}
public static List<String> validate() {
List<String> issues = new ArrayList<>();
SkillNodeRegistry.duplicateCounts().forEach((id, count) ->
issues.add(id + " was loaded " + (count + 1) + " times; node ids must be unique"));
AbilityNodeRegistry.duplicateCounts().forEach((id, count) ->
issues.add(id + " was loaded " + (count + 1) + " times; ability ids must be unique"));
for (SkillNode node : SkillNodeRegistry.all()) {
validateNode(node, issues);
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
validateAbility(ability, issues);
}
detectSkillCycles(issues);
detectAbilityCycles(issues);
return issues;
}
private static void validateNode(SkillNode node, List<String> issues) {
if (!isPngTexture(node.getIcons())) {
issues.add(node.getId() + " has invalid icon path " + node.getIcons() + " (expected a .png texture)");
}
for (ResourceLocation parent : node.getLinks()) {
if (SkillNodeRegistry.get(parent) == null) {
issues.add(node.getId() + " links to missing skill node " + parent);
}
}
for (ResourceLocation exclusive : node.getExclusiveWith()) {
SkillNode other = SkillNodeRegistry.get(exclusive);
if (other == null) {
issues.add(node.getId() + " is exclusive with missing node " + exclusive);
} else if (exclusive.equals(node.getId())) {
issues.add(node.getId() + " is exclusive with itself");
} else if (!other.getExclusiveWith().contains(node.getId())) {
issues.add(node.getId() + " is exclusive with " + exclusive + " but the reverse entry is missing");
}
}
Set<ResourceLocation> requiredSkillNodes = collectRequiredSkillNodes(node);
for (ResourceLocation exclusive : node.getExclusiveWith()) {
if (requiredSkillNodes.contains(exclusive)) {
issues.add(node.getId() + " is impossible to unlock because required ancestor " + exclusive + " is mutually exclusive");
}
}
List<ResourceLocation> requiredList = new ArrayList<>(requiredSkillNodes);
for (int i = 0; i < requiredList.size(); i++) {
SkillNode first = SkillNodeRegistry.get(requiredList.get(i));
if (first == null) continue;
for (int j = i + 1; j < requiredList.size(); j++) {
ResourceLocation second = requiredList.get(j);
if (first.getExclusiveWith().contains(second)) {
issues.add(node.getId() + " is impossible to unlock because required nodes "
+ first.getId() + " and " + second + " are mutually exclusive");
}
}
}
for (String key : node.getPrereqAttributes().keySet()) {
int separator = key.indexOf(':');
if (separator >= 0 && (separator == 0 || separator == key.length() - 1)) {
issues.add(node.getId() + " has malformed prerequisite key " + key);
continue;
}
String namespace = separator > 0 ? key.substring(0, separator) : "ascend";
if (!PrerequisiteProviderRegistry.hasProvider(namespace)) {
issues.add(node.getId() + " requires " + key + " but no prerequisite provider is registered for " + namespace);
}
}
}
private static void validateAbility(AbilityNode ability, List<String> issues) {
if (!isPngTexture(ability.getIcon())) {
issues.add(ability.getId() + " has invalid icon path " + ability.getIcon() + " (expected a .png texture)");
}
for (ResourceLocation parent : ability.getLinks()) {
if (SkillNodeRegistry.get(parent) == null && AbilityNodeRegistry.get(parent) == null) {
issues.add(ability.getId() + " links to missing node or ability " + parent);
}
}
}
private static boolean isPngTexture(ResourceLocation id) {
return id != null && id.getPath().endsWith(".png");
}
private static void detectSkillCycles(List<String> issues) {
Set<ResourceLocation> visited = new HashSet<>();
Set<ResourceLocation> visiting = new HashSet<>();
for (SkillNode node : SkillNodeRegistry.all()) {
visit(node, visited, visiting, issues);
}
}
private static void detectAbilityCycles(List<String> issues) {
Set<ResourceLocation> visited = new HashSet<>();
Set<ResourceLocation> visiting = new HashSet<>();
for (AbilityNode ability : AbilityNodeRegistry.all()) {
visitAbility(ability, visited, visiting, issues);
}
}
private static void visit(SkillNode node,
Set<ResourceLocation> visited,
Set<ResourceLocation> visiting,
List<String> issues) {
ResourceLocation id = node.getId();
if (visited.contains(id)) return;
if (!visiting.add(id)) {
issues.add("Cycle detected at skill node " + id);
return;
}
for (ResourceLocation parentId : node.getLinks()) {
SkillNode parent = SkillNodeRegistry.get(parentId);
if (parent != null) {
visit(parent, visited, visiting, issues);
}
}
visiting.remove(id);
visited.add(id);
}
private static void visitAbility(AbilityNode ability,
Set<ResourceLocation> visited,
Set<ResourceLocation> visiting,
List<String> issues) {
ResourceLocation id = ability.getId();
if (visited.contains(id)) return;
if (!visiting.add(id)) {
issues.add("Cycle detected at ability node " + id);
return;
}
for (ResourceLocation parentId : ability.getLinks()) {
AbilityNode parent = AbilityNodeRegistry.get(parentId);
if (parent != null) {
visitAbility(parent, visited, visiting, issues);
}
}
visiting.remove(id);
visited.add(id);
}
private static Set<ResourceLocation> collectRequiredSkillNodes(SkillNode node) {
Set<ResourceLocation> required = new HashSet<>();
collectRequiredSkillNodes(node, required, new HashSet<>());
required.remove(node.getId());
return required;
}
private static void collectRequiredSkillNodes(SkillNode node,
Set<ResourceLocation> required,
Set<ResourceLocation> visiting) {
if (!visiting.add(node.getId())) return;
for (ResourceLocation parentId : node.getLinks()) {
SkillNode parent = SkillNodeRegistry.get(parentId);
if (parent != null && required.add(parentId)) {
collectRequiredSkillNodes(parent, required, visiting);
}
}
visiting.remove(node.getId());
}
}