diff --git a/src/main/java/kamkeel/npcs/command/AbilityCommand.java b/src/main/java/kamkeel/npcs/command/AbilityCommand.java index 10df3cdbe..2e91c2962 100644 --- a/src/main/java/kamkeel/npcs/command/AbilityCommand.java +++ b/src/main/java/kamkeel/npcs/command/AbilityCommand.java @@ -2,6 +2,7 @@ package kamkeel.npcs.command; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityController; import kamkeel.npcs.controllers.data.ability.UserType; import net.minecraft.command.ICommandSender; import net.minecraft.entity.player.EntityPlayerMP; @@ -77,7 +78,8 @@ public class AbilityCommand extends CommandKamkeelBase { ) public void reload(ICommandSender sender, String[] args) { AbilityController.Instance.load(); - sendResult(sender, "Abilities reloaded from disk."); + ChainedAbilityController.Instance.load(); + sendResult(sender, "Abilities and chained abilities reloaded from disk."); } @SubCommand( diff --git a/src/main/java/kamkeel/npcs/controllers/SyncController.java b/src/main/java/kamkeel/npcs/controllers/SyncController.java index b6010ad86..6789e1227 100644 --- a/src/main/java/kamkeel/npcs/controllers/SyncController.java +++ b/src/main/java/kamkeel/npcs/controllers/SyncController.java @@ -41,6 +41,8 @@ import noppes.npcs.controllers.data.RecipeAnvil; import noppes.npcs.controllers.data.RecipeCarpentry; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; +import kamkeel.npcs.controllers.data.ability.ChainedAbility; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityController; import java.io.IOException; import java.util.EnumMap; @@ -76,7 +78,8 @@ public class SyncController { EnumSyncType.CUSTOM_EFFECTS, EnumSyncType.MAGIC, EnumSyncType.MAGIC_CYCLE, - EnumSyncType.CUSTOM_ABILITY + EnumSyncType.CUSTOM_ABILITY, + EnumSyncType.CHAINED_ABILITY }; public static void load() { @@ -93,6 +96,7 @@ public class SyncController { registerCache(EnumSyncType.MAGIC, SyncController::magicsNBT); registerCache(EnumSyncType.MAGIC_CYCLE, SyncController::magicCyclesNBT); registerCache(EnumSyncType.CUSTOM_ABILITY, SyncController::customAbilitiesNBT); + registerCache(EnumSyncType.CHAINED_ABILITY, SyncController::chainedAbilitiesNBT); } public static void syncPlayer(EntityPlayerMP player) { @@ -349,6 +353,27 @@ public class SyncController { updateAllPlayerRevisions(EnumSyncType.CUSTOM_ABILITY, payload.getRevision()); } + public static NBTTagCompound chainedAbilitiesNBT() { + NBTTagList list = new NBTTagList(); + NBTTagCompound compound = new NBTTagCompound(); + for (Map.Entry entry : ChainedAbilityController.Instance.getChainedAbilities().entrySet()) { + NBTTagCompound chainNBT = entry.getValue().writeNBT(); + chainNBT.setString("ChainedAbilityId", entry.getKey()); + list.appendTag(chainNBT); + } + compound.setTag("Data", list); + return compound; + } + + public static void syncAllChainedAbilities() { + CachedSyncPayload payload = rebuildNow(EnumSyncType.CHAINED_ABILITY); + if (payload == null) { + return; + } + PacketHandler.Instance.sendToAll(new SyncPacket(EnumSyncType.CHAINED_ABILITY, payload)); + updateAllPlayerRevisions(EnumSyncType.CHAINED_ABILITY, payload.getRevision()); + } + public static void syncPlayerData(EntityPlayerMP player, boolean update) { PlayerData data = PlayerData.get(player); if (data != null) { @@ -636,6 +661,20 @@ public class SyncController { AbilityController.Instance.setCustomAbilities(sync); break; } + case CHAINED_ABILITY: { + NBTTagList list = fullCompound.getTagList("Data", 10); + LinkedHashMap sync = new LinkedHashMap<>(); + for (int i = 0; i < list.tagCount(); i++) { + NBTTagCompound nbt = list.getCompoundTagAt(i); + String id = nbt.getString("ChainedAbilityId"); + ChainedAbility chain = new ChainedAbility(); + chain.readNBT(nbt); + chain.setName(id); + sync.put(id, chain); + } + ChainedAbilityController.Instance.setChainedAbilities(sync); + break; + } } ClientCacheHandler.updateClientRevision(enumSyncType, revision); @@ -886,6 +925,8 @@ public class SyncController { return EnumSet.of(EnumSyncType.CUSTOM_EFFECTS); case CUSTOM_ABILITY: return EnumSet.of(EnumSyncType.CUSTOM_ABILITY); + case CHAINED_ABILITY: + return EnumSet.of(EnumSyncType.CHAINED_ABILITY); default: return EnumSet.noneOf(EnumSyncType.class); } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java index 891558b96..624bea8ae 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -38,7 +38,7 @@ import java.util.List; import net.minecraft.entity.Entity; -public abstract class Ability implements IAbility { +public abstract class Ability implements IAbility, IAbilityAction { // ═══════════════════════════════════════════════════════════════════ // CONFIGURATION (saved to NBT) @@ -1277,6 +1277,16 @@ public abstract class Ability implements IAbility { return AbilityController.Instance.fromNBT(this.writeNBT()); } + @Override + public boolean isChain() { + return false; + } + + @Override + public IAbilityAction deepCopyAction() { + return deepCopy(); + } + public boolean isNpcInlineEdit() { return npcInlineEdit; } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityAction.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityAction.java new file mode 100644 index 000000000..6d721ff81 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityAction.java @@ -0,0 +1,247 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.nbt.NBTTagCompound; + +/** + * Unified slot for storing any combat action: inline ability, ability reference, or chain reference. + * Replaces {@link AbilitySlot} with support for all {@link IAbilityAction} types. + *

+ * References are lazy-resolved via their respective controllers and cached until + * the controller revision changes. + */ +public class AbilityAction { + + public enum SlotType { + INLINE_ABILITY, + ABILITY_REFERENCE, + CHAIN_REFERENCE + } + + private SlotType slotType; + private Ability inlineAbility; + private String referenceId; + + /** Per-slot enabled override for reference slots (null = use master's default). */ + private Boolean enabledOverride; + + // Cache + private transient IAbilityAction cachedAction; + private transient int cachedRevision = -1; + + private AbilityAction() {} + + // ═══════════════════════════════════════════════════════════════════ + // FACTORY METHODS + // ═══════════════════════════════════════════════════════════════════ + + public static AbilityAction inline(Ability ability) { + AbilityAction slot = new AbilityAction(); + slot.slotType = SlotType.INLINE_ABILITY; + slot.inlineAbility = ability; + return slot; + } + + public static AbilityAction abilityReference(String referenceId) { + AbilityAction slot = new AbilityAction(); + slot.slotType = SlotType.ABILITY_REFERENCE; + slot.referenceId = referenceId; + return slot; + } + + public static AbilityAction chainReference(String referenceId) { + AbilityAction slot = new AbilityAction(); + slot.slotType = SlotType.CHAIN_REFERENCE; + slot.referenceId = referenceId; + return slot; + } + + // ═══════════════════════════════════════════════════════════════════ + // QUERIES + // ═══════════════════════════════════════════════════════════════════ + + public SlotType getSlotType() { + return slotType; + } + + public boolean isReference() { + return slotType != SlotType.INLINE_ABILITY; + } + + public boolean isChainReference() { + return slotType == SlotType.CHAIN_REFERENCE; + } + + public boolean isAbilityReference() { + return slotType == SlotType.ABILITY_REFERENCE; + } + + public String getReferenceId() { + return referenceId; + } + + // ═══════════════════════════════════════════════════════════════════ + // RESOLUTION + // ═══════════════════════════════════════════════════════════════════ + + /** + * Resolve the action. Inline returns directly; references resolve + * from the appropriate controller with version-based caching. + */ + public IAbilityAction getAction() { + switch (slotType) { + case INLINE_ABILITY: + return inlineAbility; + + case ABILITY_REFERENCE: { + AbilityController ctrl = AbilityController.Instance; + if (ctrl == null) return null; + int rev = ctrl.getCustomAbilityRevision(); + if (cachedAction != null && cachedRevision == rev) { + return cachedAction; + } + Ability resolved = ctrl.resolveAbility(referenceId); + if (resolved != null && enabledOverride != null) { + resolved.setEnabled(enabledOverride); + } + cachedAction = resolved; + cachedRevision = rev; + return cachedAction; + } + + case CHAIN_REFERENCE: { + ChainedAbilityController ctrl = ChainedAbilityController.Instance; + if (ctrl == null) return null; + int rev = ctrl.getRevision(); + if (cachedAction != null && cachedRevision == rev) { + return cachedAction; + } + ChainedAbility resolved = ctrl.resolve(referenceId); + if (resolved != null && enabledOverride != null) { + resolved.setEnabled(enabledOverride); + } + cachedAction = resolved; + cachedRevision = rev; + return cachedAction; + } + + default: + return null; + } + } + + /** + * Get as Ability (returns null if this is a chain slot or resolution fails). + */ + public Ability getAbility() { + IAbilityAction action = getAction(); + return (action instanceof Ability) ? (Ability) action : null; + } + + /** + * Get as ChainedAbility (returns null if this is an ability slot or resolution fails). + */ + public ChainedAbility getChainedAbility() { + IAbilityAction action = getAction(); + return (action instanceof ChainedAbility) ? (ChainedAbility) action : null; + } + + // ═══════════════════════════════════════════════════════════════════ + // ENABLED OVERRIDE + // ═══════════════════════════════════════════════════════════════════ + + public void setEnabled(boolean enabled) { + if (slotType == SlotType.INLINE_ABILITY) { + if (inlineAbility != null) { + inlineAbility.setEnabled(enabled); + } + } else { + this.enabledOverride = enabled; + if (cachedAction != null) { + cachedAction.setEnabled(enabled); + } + } + } + + // ═══════════════════════════════════════════════════════════════════ + // INLINE CONVERSION (ability references only) + // ═══════════════════════════════════════════════════════════════════ + + public Ability getInlineAbility() { + return inlineAbility; + } + + /** + * Convert this reference slot to an inline slot by copying the resolved ability. + * Only works for ability references (chains cannot be inlined). + */ + public boolean convertToInline() { + if (slotType != SlotType.ABILITY_REFERENCE) return false; + + Ability resolved = getAbility(); + if (resolved == null) return false; + + inlineAbility = AbilityController.Instance.fromNBT(resolved.writeNBT()); + slotType = SlotType.INLINE_ABILITY; + referenceId = null; + cachedAction = null; + cachedRevision = -1; + return true; + } + + // ═══════════════════════════════════════════════════════════════════ + // NBT + // ═══════════════════════════════════════════════════════════════════ + + public NBTTagCompound writeNBT() { + switch (slotType) { + case CHAIN_REFERENCE: { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("ChainReference", referenceId); + if (enabledOverride != null) { + nbt.setBoolean("RefEnabled", enabledOverride); + } + return nbt; + } + + case ABILITY_REFERENCE: { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("Reference", referenceId); + if (enabledOverride != null) { + nbt.setBoolean("RefEnabled", enabledOverride); + } + return nbt; + } + + case INLINE_ABILITY: + default: + return inlineAbility != null ? inlineAbility.writeNBT() : new NBTTagCompound(); + } + } + + public static AbilityAction fromNBT(NBTTagCompound nbt) { + if (nbt == null) return null; + + // Chain reference (new format) + if (nbt.hasKey("ChainReference")) { + AbilityAction slot = chainReference(nbt.getString("ChainReference")); + if (nbt.hasKey("RefEnabled")) { + slot.enabledOverride = nbt.getBoolean("RefEnabled"); + } + return slot; + } + + // Ability reference (existing format) + if (nbt.hasKey("Reference")) { + AbilityAction slot = abilityReference(nbt.getString("Reference")); + if (nbt.hasKey("RefEnabled")) { + slot.enabledOverride = nbt.getBoolean("RefEnabled"); + } + return slot; + } + + // Inline ability (existing format) + Ability ability = AbilityController.Instance.fromNBT(nbt); + if (ability == null) return null; + return inline(ability); + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java index 406d66c12..23a718430 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java @@ -448,6 +448,36 @@ public class AbilityController implements IAbilityHandler { return false; } + // ═══════════════════════════════════════════════════════════════════════════ + // UNIFIED ACTION RESOLUTION + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Resolve any action key to an IAbilityAction. + * Checks abilities first, then chained abilities. + */ + public IAbilityAction resolveAction(String key) { + if (key == null || key.isEmpty()) return null; + + Ability ability = resolveAbility(key); + if (ability != null) return ability; + + if (ChainedAbilityController.Instance != null) { + return ChainedAbilityController.Instance.resolve(key); + } + + return null; + } + + /** + * Check if a key can be resolved as any action (ability or chain). + */ + public boolean canResolveAction(String key) { + if (canResolveAbility(key)) return true; + return ChainedAbilityController.Instance != null + && ChainedAbilityController.Instance.canResolve(key); + } + // ═══════════════════════════════════════════════════════════════════════════ // EXTENSION POINTS // ═══════════════════════════════════════════════════════════════════════════ @@ -621,4 +651,19 @@ public class AbilityController implements IAbilityHandler { public boolean deleteCustomAbilityByName(String name) { return deleteCustomAbility(name); } + + @Override + public String[] getChainedAbilityNames() { + return ChainedAbilityController.Instance.getNames().toArray(new String[0]); + } + + @Override + public boolean hasChainedAbilityName(String name) { + return ChainedAbilityController.Instance.hasName(name); + } + + @Override + public boolean deleteChainedAbilityByName(String name) { + return ChainedAbilityController.Instance.delete(name); + } } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilitySlot.java b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilitySlot.java index ab8446114..212cd7d16 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilitySlot.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilitySlot.java @@ -8,7 +8,10 @@ import net.minecraft.nbt.NBTTagCompound; *

* References are lazy-resolved via {@link AbilityController#resolveAbility(String)} * and cached until the controller version changes. + * + * @deprecated Use {@link AbilityAction} instead, which supports abilities and chained abilities. */ +@Deprecated public class AbilitySlot { private Ability inlineAbility; diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java new file mode 100644 index 000000000..1ba1a204e --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java @@ -0,0 +1,347 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.api.ability.IChainedAbility; + +import java.util.ArrayList; +import java.util.List; + +/** + * A chained ability is an ordered sequence of ability references that execute + * one after another. It has its own conditions, cooldown, weight, and per-entry + * delay configuration. Individual ability conditions are skipped during chain execution. + *

+ * All entries must be ability references (by name/key). Inline abilities are not supported. + * When saving, all references are validated against {@link AbilityController}. + */ +public class ChainedAbility implements IChainedAbility, IAbilityAction { + + // ═══════════════════════════════════════════════════════════════════ + // CONFIGURATION (saved to NBT) + // ═══════════════════════════════════════════════════════════════════ + + private String name = ""; + + private boolean enabled = true; + + /** Weight for NPC weighted random selection. */ + private int weight = 10; + + /** Whether each ability in the chain uses its own windup phase. */ + private boolean windUpAll = true; + + /** Chain-level cooldown in ticks (applied after entire chain completes). */ + private int cooldownTicks = 100; + + /** Minimum range for NPC eligibility check. */ + private float minRange = 0; + + /** Maximum range for NPC eligibility check. */ + private float maxRange = 20; + + /** Which entity types can use this chained ability. */ + private UserType allowedBy = UserType.BOTH; + + /** Chain-level conditions (individual ability conditions are ignored). */ + private List conditions = new ArrayList<>(); + + /** Ordered list of ability entries to execute. */ + private List entries = new ArrayList<>(); + + public ChainedAbility() {} + + public ChainedAbility(String name) { + this.name = name != null ? name : ""; + } + + // ═══════════════════════════════════════════════════════════════════ + // GETTERS / SETTERS + // ═══════════════════════════════════════════════════════════════════ + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name != null ? name : ""; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = Math.max(1, weight); + } + + public boolean isWindUpAll() { + return windUpAll; + } + + public void setWindUpAll(boolean windUpAll) { + this.windUpAll = windUpAll; + } + + public int getCooldownTicks() { + return cooldownTicks; + } + + public void setCooldownTicks(int cooldownTicks) { + this.cooldownTicks = Math.max(0, cooldownTicks); + } + + public float getMinRange() { + return minRange; + } + + public void setMinRange(float minRange) { + this.minRange = Math.max(0, minRange); + } + + public float getMaxRange() { + return maxRange; + } + + public void setMaxRange(float maxRange) { + this.maxRange = Math.max(0, maxRange); + } + + public UserType getAllowedBy() { + return allowedBy; + } + + public void setAllowedBy(UserType allowedBy) { + this.allowedBy = allowedBy != null ? allowedBy : UserType.BOTH; + } + + public List getConditions() { + return conditions; + } + + public void setConditions(List conditions) { + this.conditions = conditions != null ? conditions : new ArrayList<>(); + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries != null ? entries : new ArrayList<>(); + } + + // ═══════════════════════════════════════════════════════════════════ + // IChainedAbility INTERFACE + // ═══════════════════════════════════════════════════════════════════ + + @Override + public int getEntryCount() { + return entries.size(); + } + + @Override + public String getEntryReference(int index) { + if (index < 0 || index >= entries.size()) return null; + return entries.get(index).getAbilityReference(); + } + + @Override + public int getEntryDelay(int index) { + if (index < 0 || index >= entries.size()) return 0; + return entries.get(index).getDelayTicks(); + } + + // ═══════════════════════════════════════════════════════════════════ + // ENTRY MANAGEMENT + // ═══════════════════════════════════════════════════════════════════ + + public void addEntry(ChainedAbilityEntry entry) { + if (entry != null) { + entries.add(entry); + } + } + + public void addEntry(String abilityReference, int delayTicks) { + entries.add(new ChainedAbilityEntry(abilityReference, delayTicks)); + } + + public void removeEntry(int index) { + if (index >= 0 && index < entries.size()) { + entries.remove(index); + } + } + + public void moveEntryUp(int index) { + if (index > 0 && index < entries.size()) { + ChainedAbilityEntry entry = entries.remove(index); + entries.add(index - 1, entry); + } + } + + public void moveEntryDown(int index) { + if (index >= 0 && index < entries.size() - 1) { + ChainedAbilityEntry entry = entries.remove(index); + entries.add(index + 1, entry); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // CONDITIONS + // ═══════════════════════════════════════════════════════════════════ + + /** + * Check all chain-level conditions against the caster and target. + */ + public boolean checkConditions(EntityLivingBase caster, EntityLivingBase target) { + for (Condition c : conditions) { + if (!c.check(caster, target)) { + return false; + } + } + return true; + } + + /** + * Check conditions for player use (skip target-requiring conditions). + */ + public boolean checkConditionsForPlayer(EntityLivingBase caster) { + for (Condition c : conditions) { + if (c.requiresTarget()) continue; + if (!c.check(caster, null)) { + return false; + } + } + return true; + } + + // ═══════════════════════════════════════════════════════════════════ + // VALIDATION + // ═══════════════════════════════════════════════════════════════════ + + /** + * Validate this chained ability. Returns a list of error messages. + * An empty list means valid. + */ + public List validate() { + List errors = new ArrayList<>(); + + if (name == null || name.isEmpty()) { + errors.add("Name is required"); + } + + if (entries.isEmpty()) { + errors.add("At least one ability entry is required"); + } + + AbilityController controller = AbilityController.Instance; + if (controller != null) { + for (int i = 0; i < entries.size(); i++) { + ChainedAbilityEntry entry = entries.get(i); + String ref = entry.getAbilityReference(); + if (ref == null || ref.isEmpty()) { + errors.add("Entry " + (i + 1) + ": ability reference is empty"); + } else if (!controller.canResolveAbility(ref)) { + errors.add("Entry " + (i + 1) + ": cannot resolve ability '" + ref + "'"); + } + } + } + + return errors; + } + + // ═══════════════════════════════════════════════════════════════════ + // DEEP COPY + // ═══════════════════════════════════════════════════════════════════ + + /** + * Create a deep copy via NBT round-trip. + */ + public ChainedAbility deepCopy() { + ChainedAbility copy = new ChainedAbility(); + copy.readNBT(this.writeNBT()); + return copy; + } + + @Override + public boolean isChain() { + return true; + } + + @Override + public IAbilityAction deepCopyAction() { + return deepCopy(); + } + + // ═══════════════════════════════════════════════════════════════════ + // NBT SERIALIZATION + // ═══════════════════════════════════════════════════════════════════ + + public NBTTagCompound writeNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("Name", name); + nbt.setBoolean("Enabled", enabled); + nbt.setInteger("Weight", weight); + nbt.setBoolean("WindUpAll", windUpAll); + nbt.setInteger("CooldownTicks", cooldownTicks); + nbt.setFloat("MinRange", minRange); + nbt.setFloat("MaxRange", maxRange); + nbt.setInteger("AllowedBy", allowedBy.ordinal()); + + // Conditions + NBTTagList condList = new NBTTagList(); + for (Condition c : conditions) { + condList.appendTag(c.writeNBT()); + } + nbt.setTag("Conditions", condList); + + // Entries + NBTTagList entryList = new NBTTagList(); + for (ChainedAbilityEntry entry : entries) { + entryList.appendTag(entry.writeNBT()); + } + nbt.setTag("Entries", entryList); + + return nbt; + } + + public void readNBT(NBTTagCompound nbt) { + name = nbt.getString("Name"); + enabled = !nbt.hasKey("Enabled") || nbt.getBoolean("Enabled"); + weight = nbt.hasKey("Weight") ? nbt.getInteger("Weight") : 10; + windUpAll = !nbt.hasKey("WindUpAll") || nbt.getBoolean("WindUpAll"); + cooldownTicks = nbt.hasKey("CooldownTicks") ? nbt.getInteger("CooldownTicks") : 100; + minRange = nbt.getFloat("MinRange"); + maxRange = nbt.hasKey("MaxRange") ? nbt.getFloat("MaxRange") : 20; + allowedBy = UserType.fromOrdinal(nbt.getInteger("AllowedBy")); + + // Conditions + conditions.clear(); + NBTTagList condList = nbt.getTagList("Conditions", 10); + for (int i = 0; i < condList.tagCount(); i++) { + Condition c = Condition.fromNBT(condList.getCompoundTagAt(i)); + if (c != null) { + conditions.add(c); + } + } + + // Entries + entries.clear(); + NBTTagList entryList = nbt.getTagList("Entries", 10); + for (int i = 0; i < entryList.tagCount(); i++) { + ChainedAbilityEntry entry = ChainedAbilityEntry.fromNBT(entryList.getCompoundTagAt(i)); + if (entry != null) { + entries.add(entry); + } + } + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java new file mode 100644 index 000000000..05819dea4 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java @@ -0,0 +1,207 @@ +package kamkeel.npcs.controllers.data.ability; + +import kamkeel.npcs.controllers.SyncController; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.CustomNpcs; +import noppes.npcs.LogWriter; +import noppes.npcs.util.NBTJsonUtil; + +import java.io.File; +import java.util.*; + +/** + * Global registry and storage for {@link ChainedAbility} definitions. + * Follows the same patterns as {@link AbilityController} for custom abilities: + * JSON file storage, revision tracking for cache invalidation, and client sync. + */ +public class ChainedAbilityController { + + public static ChainedAbilityController Instance = new ChainedAbilityController(); + + private final Map chainedAbilities = new LinkedHashMap<>(); + + /** Incremented on any change; used for cache invalidation. */ + private int revision = 0; + + // ═══════════════════════════════════════════════════════════════════ + // LOAD / SAVE + // ═══════════════════════════════════════════════════════════════════ + + /** + * Load all chained abilities from disk. + */ + public void load() { + chainedAbilities.clear(); + + File dir = getDir(); + File[] files = dir.exists() ? dir.listFiles() : null; + if (files != null) { + for (File file : files) { + if (!file.isFile() || !file.getName().endsWith(".json")) continue; + try { + String filename = file.getName(); + String key = filename.substring(0, filename.length() - 5); + + NBTTagCompound nbt = NBTJsonUtil.LoadFile(file); + ChainedAbility chain = new ChainedAbility(); + chain.readNBT(nbt); + + // Use filename as authoritative key + chain.setName(key); + chainedAbilities.put(key, chain); + } catch (Exception e) { + LogWriter.error("Error loading chained ability: " + file.getAbsolutePath(), e); + } + } + } + + revision++; + LogWriter.info("Loaded " + chainedAbilities.size() + " chained abilities"); + } + + /** + * Save a chained ability to disk. Handles renames and uniqueness. + */ + public boolean save(ChainedAbility chain) { + if (chain == null) return false; + + String name = chain.getName(); + if (name == null || name.isEmpty()) return false; + + File dir = getDir(); + File fileNew = new File(dir, name + ".json_new"); + File fileCurrent = new File(dir, name + ".json"); + + try { + NBTTagCompound nbt = chain.writeNBT(); + NBTJsonUtil.SaveFile(fileNew, nbt); + + if (fileCurrent.exists()) { + fileCurrent.delete(); + } + fileNew.renameTo(fileCurrent); + if (fileNew.exists()) { + fileNew.delete(); + } + + chainedAbilities.put(name, chain); + revision++; + LogWriter.info("Saved chained ability: " + name); + SyncController.syncAllChainedAbilities(); + return true; + } catch (Exception e) { + LogWriter.error("Error saving chained ability: " + name, e); + return false; + } + } + + /** + * Delete a chained ability by name. + */ + public boolean delete(String name) { + if (name == null || name.isEmpty()) return false; + + ChainedAbility removed = chainedAbilities.remove(name); + if (removed == null) return false; + + File dir = getDir(); + File file = new File(dir, name + ".json"); + if (file.exists()) { + file.delete(); + } + + revision++; + LogWriter.info("Deleted chained ability: " + name); + SyncController.syncAllChainedAbilities(); + return true; + } + + // ═══════════════════════════════════════════════════════════════════ + // RESOLUTION + // ═══════════════════════════════════════════════════════════════════ + + /** + * Resolve a chained ability by name, returning a deep copy. + * Tries exact match first, then case-insensitive. + */ + public ChainedAbility resolve(String name) { + if (name == null || name.isEmpty()) return null; + + // Exact match + ChainedAbility chain = chainedAbilities.get(name); + if (chain != null) return chain.deepCopy(); + + // Case-insensitive match + for (Map.Entry entry : chainedAbilities.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue().deepCopy(); + } + } + + return null; + } + + /** + * Check if a name can be resolved without creating a deep copy. + */ + public boolean canResolve(String name) { + if (name == null || name.isEmpty()) return false; + + if (chainedAbilities.containsKey(name)) return true; + + for (String key : chainedAbilities.keySet()) { + if (key.equalsIgnoreCase(name)) return true; + } + + return false; + } + + // ═══════════════════════════════════════════════════════════════════ + // QUERIES + // ═══════════════════════════════════════════════════════════════════ + + public ChainedAbility get(String name) { + return chainedAbilities.get(name); + } + + public Set getNames() { + return new LinkedHashSet<>(chainedAbilities.keySet()); + } + + public Map getChainedAbilities() { + return chainedAbilities; + } + + public boolean hasName(String name) { + return chainedAbilities.containsKey(name); + } + + public int getRevision() { + return revision; + } + + // ═══════════════════════════════════════════════════════════════════ + // CLIENT SYNC + // ═══════════════════════════════════════════════════════════════════ + + /** + * Set chained abilities from sync payload (client-side). + */ + public void setChainedAbilities(Map synced) { + chainedAbilities.clear(); + chainedAbilities.putAll(synced); + revision++; + } + + // ═══════════════════════════════════════════════════════════════════ + // STORAGE + // ═══════════════════════════════════════════════════════════════════ + + private File getDir() { + File dir = new File(CustomNpcs.getWorldSaveDirectory(), "chained_abilities"); + if (!dir.exists()) { + dir.mkdirs(); + } + return dir; + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java new file mode 100644 index 000000000..ace05d69d --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityEntry.java @@ -0,0 +1,63 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.nbt.NBTTagCompound; + +/** + * A single entry in a {@link ChainedAbility}, representing one ability + * to execute in sequence along with its delay configuration. + */ +public class ChainedAbilityEntry { + + /** Reference key (built-in name or custom ability name). */ + private String abilityReference = ""; + + /** Delay in ticks BEFORE this ability starts (after previous entry completes). */ + private int delayTicks = 0; + + public ChainedAbilityEntry() {} + + public ChainedAbilityEntry(String abilityReference, int delayTicks) { + this.abilityReference = abilityReference != null ? abilityReference : ""; + this.delayTicks = Math.max(0, delayTicks); + } + + // ═══════════════════════════════════════════════════════════════════ + // GETTERS / SETTERS + // ═══════════════════════════════════════════════════════════════════ + + public String getAbilityReference() { + return abilityReference; + } + + public void setAbilityReference(String abilityReference) { + this.abilityReference = abilityReference != null ? abilityReference : ""; + } + + public int getDelayTicks() { + return delayTicks; + } + + public void setDelayTicks(int delayTicks) { + this.delayTicks = Math.max(0, delayTicks); + } + + // ═══════════════════════════════════════════════════════════════════ + // NBT + // ═══════════════════════════════════════════════════════════════════ + + public NBTTagCompound writeNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("Reference", abilityReference); + nbt.setInteger("Delay", delayTicks); + return nbt; + } + + public static ChainedAbilityEntry fromNBT(NBTTagCompound nbt) { + if (nbt == null) return null; + + ChainedAbilityEntry entry = new ChainedAbilityEntry(); + entry.abilityReference = nbt.getString("Reference"); + entry.delayTicks = Math.max(0, nbt.getInteger("Delay")); + return entry; + } +} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/IAbilityAction.java b/src/main/java/kamkeel/npcs/controllers/data/ability/IAbilityAction.java new file mode 100644 index 000000000..a1f4ac777 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/IAbilityAction.java @@ -0,0 +1,50 @@ +package kamkeel.npcs.controllers.data.ability; + +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.nbt.NBTTagCompound; + +import java.util.List; + +/** + * Common interface for all combat actions that can be assigned to NPCs or players. + * Implemented by {@link Ability} (individual ability) and {@link ChainedAbility} (sequence). + *

+ * This enables unified storage via {@link AbilityAction}, unified weighted random selection, + * and unified eligibility checking without instanceof or separate codepaths. + */ +public interface IAbilityAction { + + String getName(); + + boolean isEnabled(); + + void setEnabled(boolean enabled); + + int getWeight(); + + int getCooldownTicks(); + + float getMinRange(); + + float getMaxRange(); + + UserType getAllowedBy(); + + List getConditions(); + + boolean checkConditions(EntityLivingBase caster, EntityLivingBase target); + + boolean checkConditionsForPlayer(EntityLivingBase caster); + + /** + * Whether this action is a chained ability (sequence of abilities) vs an individual ability. + */ + boolean isChain(); + + /** + * Create a deep copy of this action. + */ + IAbilityAction deepCopyAction(); + + NBTTagCompound writeNBT(); +} diff --git a/src/main/java/kamkeel/npcs/network/enums/EnumSyncType.java b/src/main/java/kamkeel/npcs/network/enums/EnumSyncType.java index 1ac0ec828..ee6cf352f 100644 --- a/src/main/java/kamkeel/npcs/network/enums/EnumSyncType.java +++ b/src/main/java/kamkeel/npcs/network/enums/EnumSyncType.java @@ -13,5 +13,6 @@ public enum EnumSyncType { CARPENTRY_RECIPES, ANVIL_RECIPES, CUSTOM_EFFECTS, - CUSTOM_ABILITY + CUSTOM_ABILITY, + CHAINED_ABILITY } diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index 774e9661e..11ff57104 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -3,6 +3,8 @@ package noppes.npcs; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; import kamkeel.npcs.controllers.data.ability.AbilityPhase; +import kamkeel.npcs.controllers.data.ability.ChainedAbility; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityEntry; import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance; import net.minecraft.entity.EntityLivingBase; import net.minecraft.util.DamageSource; @@ -45,6 +47,14 @@ public abstract class AbstractDataAbilities { protected double lockedPosY = 0; protected double lockedPosZ = 0; + /** Chained ability execution state */ + protected ChainedAbility currentChain; + protected int chainEntryIndex = -1; + protected int chainDelayRemaining = -1; + + /** Set when interrupt already rolled cooldown (prevents double cooldown on NPC dazed completion). */ + protected boolean interruptCooldownRolled = false; + // ═══════════════════════════════════════════════════════════════════ // ABSTRACT METHODS - Subclasses must implement // ═══════════════════════════════════════════════════════════════════ @@ -108,6 +118,12 @@ public abstract class AbstractDataAbilities { /** Additional completion logic (NPC clears lastTarget, Player clears key/target and syncs). */ protected abstract void onAbilityComplete(); + /** + * Roll cooldown after a chained ability completes. + * NPC: random(min, max) + chain.cooldownTicks. Player: chain.cooldownTicks only. + */ + protected abstract void rollChainCooldown(ChainedAbility chain); + // -- Hooks (optional overrides) -- @@ -120,6 +136,9 @@ public abstract class AbstractDataAbilities { /** Additional lock releases in BURST_DELAY. NPC releases hitScan. */ protected void onBurstDelayReleaseLocks() {} + /** Hook for retargeting during chain execution when target dies. NPC overrides, Player returns null. */ + protected EntityLivingBase retargetForChain() { return null; } + // ═══════════════════════════════════════════════════════════════════ // SHARED ANIMATION METHODS // ═══════════════════════════════════════════════════════════════════ @@ -168,6 +187,16 @@ public abstract class AbstractDataAbilities { return currentAbility; } + /** Check if a chained ability is currently executing. */ + public boolean isExecutingChain() { + return currentChain != null; + } + + /** Get the currently executing chained ability. */ + public ChainedAbility getCurrentChain() { + return currentChain; + } + // ═══════════════════════════════════════════════════════════════════ // COOLDOWN MANAGEMENT // ═══════════════════════════════════════════════════════════════════ @@ -242,6 +271,24 @@ public abstract class AbstractDataAbilities { * Subclass-specific behavior is injected via abstract methods and hooks. */ protected void tickCurrentAbility() { + // Handle chain delay between entries + if (chainDelayRemaining > 0) { + chainDelayRemaining--; + if (chainDelayRemaining <= 0) { + EntityLivingBase chainTarget = getTarget(); + // Check if target is dead and retarget + if (currentChain != null && chainTarget != null && chainTarget.isDead) { + chainTarget = retargetForChain(); + if (chainTarget == null) { + completeChain(); + return; + } + } + startChainEntry(chainTarget); + } + return; + } + EntityLivingBase entity = getEntity(); EntityLivingBase target = getTarget(); AbilityPhase oldPhase = currentAbility.getPhase(); @@ -381,6 +428,7 @@ public abstract class AbstractDataAbilities { /** * Handle ability completion. Called when ability phase becomes IDLE. + * If a chain is active, advances to the next entry instead of completing. */ protected void handleAbilityCompletion(EntityLivingBase target) { if (currentAbility == null) return; @@ -400,10 +448,44 @@ public abstract class AbstractDataAbilities { // Release locks releaseRotationControl(); releaseLockedPosition(); - - // Subclass-specific completion (cooldown, cleanup, sync) - rollCooldown(currentAbility); stopAbilityAnimation(); + + // Chain mode: advance to next entry instead of completing + if (currentChain != null) { + chainEntryIndex++; + if (chainEntryIndex < currentChain.getEntries().size()) { + ChainedAbilityEntry nextEntry = currentChain.getEntries().get(chainEntryIndex); + + // Check if target died and retarget + if (target != null && target.isDead) { + target = retargetForChain(); + if (target == null) { + completeChain(); + return; + } + } + + int delay = nextEntry.getDelayTicks(); + if (delay > 0) { + chainDelayRemaining = delay; + currentAbility = null; + } else { + startChainEntry(target); + } + return; + } + // All entries complete + completeChain(); + return; + } + + // Normal (non-chain) completion + if (interruptCooldownRolled) { + // Cooldown was already rolled during interrupt (e.g., chain interrupted, NPC ticked through DAZED) + interruptCooldownRolled = false; + } else { + rollCooldown(currentAbility); + } onAbilityComplete(); } @@ -465,6 +547,15 @@ public abstract class AbstractDataAbilities { releaseRotationControl(); releaseLockedPosition(); + // If chain was active, roll chain cooldown and clear chain state + if (currentChain != null) { + rollChainCooldown(currentChain); + interruptCooldownRolled = true; + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; + } + // Let subclass handle cleanup onInterruptComplete(); } @@ -475,4 +566,112 @@ public abstract class AbstractDataAbilities { * NPC: keeps currentAbility (ticks through DAZED). Player: clears immediately. */ protected void onInterruptComplete() {} + + // ═══════════════════════════════════════════════════════════════════ + // CHAINED ABILITY EXECUTION + // ═══════════════════════════════════════════════════════════════════ + + /** + * Start executing a chained ability. Sets up chain state and starts the first entry. + * + * @param chain The chained ability to execute (should be a deep copy) + * @param target The initial target + * @return true if the chain was started + */ + protected boolean startChain(ChainedAbility chain, EntityLivingBase target) { + if (chain == null || chain.getEntries().isEmpty()) return false; + + currentChain = chain; + chainEntryIndex = 0; + chainDelayRemaining = -1; + + // Check if first entry has a delay + ChainedAbilityEntry firstEntry = chain.getEntries().get(0); + int delay = firstEntry.getDelayTicks(); + if (delay > 0) { + chainDelayRemaining = delay; + return true; + } + + return startChainEntry(target); + } + + /** + * Start the current chain entry's ability. + */ + protected boolean startChainEntry(EntityLivingBase target) { + if (currentChain == null || chainEntryIndex < 0 || chainEntryIndex >= currentChain.getEntries().size()) { + completeChain(); + return false; + } + + ChainedAbilityEntry entry = currentChain.getEntries().get(chainEntryIndex); + Ability ability = AbilityController.Instance.resolveAbility(entry.getAbilityReference()); + if (ability == null) { + // Broken reference - complete chain + completeChain(); + return false; + } + + // Validate the resolved ability's UserType matches the chain's allowed context. + // The chain's own UserType was already checked at selection time, but individual + // abilities may have stricter restrictions (e.g., PLAYER_ONLY in an NPC chain). + if (!ability.getAllowedBy().allowsNpc() && getEntity() instanceof noppes.npcs.entity.EntityNPCInterface) { + // Skip this entry - ability doesn't allow NPCs + completeChain(); + return false; + } + if (!ability.getAllowedBy().allowsPlayer() && getEntity() instanceof net.minecraft.entity.player.EntityPlayer) { + // Skip this entry - ability doesn't allow players + completeChain(); + return false; + } + + // If windUpAll=false, force windup to 0 (skip individual windups) + if (!currentChain.isWindUpAll()) { + ability.setWindUpTicks(0); + } + + // Start the ability (skip its own conditions - chain conditions were already checked) + currentAbility = ability; + ability.start(target); + + if (ability.getPhase() == AbilityPhase.ACTIVE) { + // Windup was 0 - capture locks for immediate active phase, then execute + if (ability.isRotationLockedDuringActive()) { + captureLockedRotation(); + } + if (ability.isMovementLockedDuringActive() && !ability.hasAbilityMovement()) { + captureLockedPosition(); + } + executeImmediate(ability, target); + } else { + // Normal windup flow + if (ability.isRotationLockedDuringWindup()) { + captureLockedRotation(); + } + if (ability.isMovementLockedDuringWindup() && !ability.hasAbilityMovement()) { + captureLockedPosition(); + } + spawnTelegraph(ability, target); + playAbilitySound(ability.getWindUpSound()); + playAbilityAnimation(ability.getWindUpAnimation()); + } + + return true; + } + + /** + * Complete the chain execution. Rolls chain cooldown and clears all chain state. + */ + protected void completeChain() { + if (currentChain != null) { + rollChainCooldown(currentChain); + } + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; + currentAbility = null; + onAbilityComplete(); + } } diff --git a/src/main/java/noppes/npcs/CustomNpcs.java b/src/main/java/noppes/npcs/CustomNpcs.java index 892c3648f..e1dcf489b 100644 --- a/src/main/java/noppes/npcs/CustomNpcs.java +++ b/src/main/java/noppes/npcs/CustomNpcs.java @@ -26,6 +26,7 @@ import kamkeel.npcs.controllers.ProfileController; import kamkeel.npcs.controllers.SyncController; import kamkeel.npcs.controllers.TelegraphController; import kamkeel.npcs.controllers.data.ability.AbilityController; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityController; import kamkeel.npcs.controllers.data.profile.CNPCData; import kamkeel.npcs.developer.Developer; import kamkeel.npcs.network.PacketHandler; @@ -355,6 +356,7 @@ public class CustomNpcs { new SpawnController(); new LinkedNpcController(); AbilityController.Instance.load(); + ChainedAbilityController.Instance.load(); TelegraphController.init(); LinkedItemController.getInstance().load(); diff --git a/src/main/java/noppes/npcs/DataAbilities.java b/src/main/java/noppes/npcs/DataAbilities.java index cb1e5104d..52a086cce 100644 --- a/src/main/java/noppes/npcs/DataAbilities.java +++ b/src/main/java/noppes/npcs/DataAbilities.java @@ -3,7 +3,10 @@ package noppes.npcs; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; import kamkeel.npcs.controllers.data.ability.AbilityPhase; -import kamkeel.npcs.controllers.data.ability.AbilitySlot; +import kamkeel.npcs.controllers.data.ability.AbilityAction; +import kamkeel.npcs.controllers.data.ability.ChainedAbility; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityController; +import kamkeel.npcs.controllers.data.ability.IAbilityAction; import kamkeel.npcs.controllers.data.ability.type.AbilityGuard; import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance; import kamkeel.npcs.network.packets.data.telegraph.TelegraphRemovePacket; @@ -35,9 +38,9 @@ public class DataAbilities extends AbstractDataAbilities { // ═══════════════════════════════════════════════════════════════════ /** - * List of ability slots this NPC can use (inline or reference). + * Unified list of action slots (abilities and chained abilities). */ - private List abilitySlots = new ArrayList<>(); + private List actionSlots = new ArrayList<>(); /** * Whether the ability system is enabled for this NPC @@ -207,6 +210,20 @@ public class DataAbilities extends AbstractDataAbilities { cooldownEndTime += ability.getCooldownTicks(); } + @Override + protected void rollChainCooldown(ChainedAbility chain) { + int baseCooldown = minCooldown; + if (maxCooldown > minCooldown) { + baseCooldown = minCooldown + random.nextInt(maxCooldown - minCooldown + 1); + } + cooldownEndTime = npc.worldObj.getTotalWorldTime() + baseCooldown + chain.getCooldownTicks(); + } + + @Override + protected EntityLivingBase retargetForChain() { + return npc.getAttackTarget(); + } + @Override protected void onAbilityComplete() { currentAbility = null; @@ -288,17 +305,21 @@ public class DataAbilities extends AbstractDataAbilities { if (rotationLocked || hitScanActive) releaseRotationControl(); if (positionLocked) releaseLockedPosition(); } + // Clear chain state + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; return; } // Safety: release orphaned locks if no ability is actively executing - if (currentAbility == null || !currentAbility.isExecuting()) { + if ((currentAbility == null || !currentAbility.isExecuting()) && chainDelayRemaining <= 0) { if (rotationLocked || hitScanActive) releaseRotationControl(); if (positionLocked) releaseLockedPosition(); } - // Tick current ability if executing (shared logic from base class) - if (currentAbility != null && currentAbility.isExecuting()) { + // Tick current ability if executing, or tick chain delay between entries + if (chainDelayRemaining > 0 || (currentAbility != null && currentAbility.isExecuting())) { tickCurrentAbility(); } } @@ -329,7 +350,7 @@ public class DataAbilities extends AbstractDataAbilities { // ═══════════════════════════════════════════════════════════════════ /** - * Try to select and start an ability for the given target. + * Try to select and start an ability or chained ability for the given target. * Called by CombatHandler when NPC is in combat and ready for an ability. * * @param target The combat target @@ -340,24 +361,61 @@ public class DataAbilities extends AbstractDataAbilities { return false; } - Ability selected = selectAbility(target); - if (selected == null) { + // Build eligible pool from unified action slots + List eligible = new ArrayList<>(); + List weights = new ArrayList<>(); + int totalWeight = 0; + + for (AbilityAction slot : actionSlots) { + IAbilityAction action = slot.getAction(); + if (action != null && isActionEligible(action, target)) { + eligible.add(action); + weights.add(action.getWeight()); + totalWeight += action.getWeight(); + } + } + + if (eligible.isEmpty() || totalWeight <= 0) { return false; } - return startAbility(selected, target); + // Weighted random selection + int roll = random.nextInt(totalWeight); + int cumulative = 0; + IAbilityAction selected = null; + for (int i = 0; i < eligible.size(); i++) { + cumulative += weights.get(i); + if (roll < cumulative) { + selected = eligible.get(i); + break; + } + } + if (selected == null) { + selected = eligible.get(eligible.size() - 1); + } + + // Dispatch + lastTarget = target; + if (selected.isChain()) { + return startChain((ChainedAbility) selected, target); + } else { + return startAbility((Ability) selected, target); + } } /** * Check if an ability can be selected right now. */ public boolean canSelectAbility() { - if (!enabled || abilitySlots.isEmpty()) { + if (!enabled || actionSlots.isEmpty()) { return false; } if (currentAbility != null && currentAbility.isExecuting()) { return false; } + if (isExecutingChain()) { + return false; + } // Check if still on cooldown if (npc.worldObj.getTotalWorldTime() < cooldownEndTime) { return false; @@ -366,65 +424,36 @@ public class DataAbilities extends AbstractDataAbilities { } /** - * Select an ability using weighted random from eligible abilities. + * Check if an action (ability or chain) is eligible for use. */ - private Ability selectAbility(EntityLivingBase target) { - List eligible = new ArrayList<>(); - int totalWeight = 0; - - for (Ability ability : getAbilities()) { - if (isAbilityEligible(ability, target)) { - eligible.add(ability); - totalWeight += ability.getWeight(); - } + private boolean isActionEligible(IAbilityAction action, EntityLivingBase target) { + if (!action.getAllowedBy().allowsNpc()) { + return false; } - - if (eligible.isEmpty() || totalWeight <= 0) { - return null; - } - - // Weighted random selection - int roll = random.nextInt(totalWeight); - int cumulative = 0; - for (Ability ability : eligible) { - cumulative += ability.getWeight(); - if (roll < cumulative) { - return ability; - } - } - - return eligible.get(eligible.size() - 1); - } - - /** - * Check if a specific ability is eligible for use. - */ - private boolean isAbilityEligible(Ability ability, EntityLivingBase target) { - // Check UserType allows NPCs - if (!ability.getAllowedBy().allowsNpc()) { + if (!action.isEnabled()) { return false; } - // Check enabled - if (!ability.isEnabled()) { + // Ability-specific: check if already executing + if (!action.isChain() && ((Ability) action).isExecuting()) { return false; } - // Check if already executing - if (ability.isExecuting()) { + // Chain-specific: must have entries + if (action.isChain() && ((ChainedAbility) action).getEntries().isEmpty()) { return false; } - // Check range + // Range check if (target != null) { float distance = npc.getDistanceToEntity(target); - if (distance < ability.getMinRange() || distance > ability.getMaxRange()) { + if (distance < action.getMinRange() || distance > action.getMaxRange()) { return false; } } - // Check conditions - if (!ability.checkConditions(npc, target)) { + // Conditions + if (!action.checkConditions(npc, target)) { return false; } @@ -496,7 +525,9 @@ public class DataAbilities extends AbstractDataAbilities { return false; } - currentAbility.onDamageTaken(npc, (EntityLivingBase) source.getEntity(), source, amount); + net.minecraft.entity.Entity sourceEntity = source.getEntity(); + EntityLivingBase attacker = sourceEntity instanceof EntityLivingBase ? (EntityLivingBase) sourceEntity : null; + currentAbility.onDamageTaken(npc, attacker, source, amount); if (currentAbility.canInterrupt(source)) { interruptCurrentAbility(source, amount); @@ -568,6 +599,10 @@ public class DataAbilities extends AbstractDataAbilities { currentAbility = null; lastTarget = null; } + // Also clear chain state + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; } /** @@ -687,15 +722,20 @@ public class DataAbilities extends AbstractDataAbilities { } // ═══════════════════════════════════════════════════════════════════ - // ABILITY LIST MANAGEMENT + // ACTION SLOT MANAGEMENT // ═══════════════════════════════════════════════════════════════════ + /** Get the unified action slot list. */ + public List getAbilityActions() { + return actionSlots; + } + /** - * Get resolved abilities from all slots, filtering out broken references. + * Get resolved abilities from all ability slots, filtering out broken references and chains. */ public List getAbilities() { List resolved = new ArrayList<>(); - for (AbilitySlot slot : abilitySlots) { + for (AbilityAction slot : actionSlots) { Ability a = slot.getAbility(); if (a != null) { resolved.add(a); @@ -704,39 +744,54 @@ public class DataAbilities extends AbstractDataAbilities { return resolved; } - /** Get the raw slot list (for GUI access). */ - public List getAbilitySlots() { - return abilitySlots; - } - /** Add an inline ability. */ public void addAbility(Ability ability) { - abilitySlots.add(AbilitySlot.inline(ability)); + actionSlots.add(AbilityAction.inline(ability)); } /** Add a reference ability by key (built-in name or custom ability name). */ public void addAbilityReference(String key) { - abilitySlots.add(AbilitySlot.reference(key)); + actionSlots.add(AbilityAction.abilityReference(key)); } - public void removeAbility(int index) { - if (index >= 0 && index < abilitySlots.size()) { - abilitySlots.remove(index); + /** Add a chained ability reference by name. */ + public void addChainReference(String name) { + if (name != null && !name.isEmpty()) { + for (AbilityAction slot : actionSlots) { + if (slot.isChainReference() && name.equals(slot.getReferenceId())) { + return; // Prevent duplicates + } + } + actionSlots.add(AbilityAction.chainReference(name)); } } + /** Remove an action slot by index. */ + public void removeAction(int index) { + if (index >= 0 && index < actionSlots.size()) { + actionSlots.remove(index); + } + } + + public void removeAbility(int index) { + removeAction(index); + } + public void removeAbility(String id) { - abilitySlots.removeIf(slot -> { - if (slot.isReference()) { + actionSlots.removeIf(slot -> { + if (slot.isAbilityReference()) { return slot.getReferenceId().equals(id); } + if (slot.isChainReference()) { + return false; // Don't remove chain references via removeAbility + } Ability a = slot.getAbility(); return a != null && a.getId().equals(id); }); } public Ability getAbility(String id) { - for (AbilitySlot slot : abilitySlots) { + for (AbilityAction slot : actionSlots) { Ability a = slot.getAbility(); if (a != null && a.getId().equals(id)) { return a; @@ -747,22 +802,22 @@ public class DataAbilities extends AbstractDataAbilities { /** Check if a slot at a given index is a reference. */ public boolean isSlotReference(int index) { - if (index < 0 || index >= abilitySlots.size()) return false; - return abilitySlots.get(index).isReference(); + if (index < 0 || index >= actionSlots.size()) return false; + return actionSlots.get(index).isReference(); } - /** Convert a reference slot to inline. Returns false if resolution fails. */ + /** Convert a reference slot to inline. Returns false if resolution fails or is a chain. */ public boolean convertToInline(int index) { - if (index < 0 || index >= abilitySlots.size()) return false; - return abilitySlots.get(index).convertToInline(); + if (index < 0 || index >= actionSlots.size()) return false; + return actionSlots.get(index).convertToInline(); } public void clearAbilities() { - abilitySlots.clear(); + actionSlots.clear(); } public boolean isEmpty() { - return abilitySlots.isEmpty(); + return actionSlots.isEmpty(); } // ═══════════════════════════════════════════════════════════════════ @@ -977,11 +1032,11 @@ public class DataAbilities extends AbstractDataAbilities { compound.setInteger("AbilityMinCooldown", minCooldown); compound.setInteger("AbilityMaxCooldown", maxCooldown); - NBTTagList abilityList = new NBTTagList(); - for (AbilitySlot slot : abilitySlots) { - abilityList.appendTag(slot.writeNBT()); + NBTTagList actionList = new NBTTagList(); + for (AbilityAction slot : actionSlots) { + actionList.appendTag(slot.writeNBT()); } - compound.setTag("Abilities", abilityList); + compound.setTag("AbilityActions", actionList); return compound; } @@ -991,13 +1046,36 @@ public class DataAbilities extends AbstractDataAbilities { minCooldown = compound.getInteger("AbilityMinCooldown"); maxCooldown = compound.getInteger("AbilityMaxCooldown"); - abilitySlots.clear(); - NBTTagList abilityList = compound.getTagList("Abilities", 10); - for (int i = 0; i < abilityList.tagCount(); i++) { - NBTTagCompound abilityNBT = abilityList.getCompoundTagAt(i); - AbilitySlot slot = AbilitySlot.fromNBT(abilityNBT); - if (slot != null) { - abilitySlots.add(slot); + actionSlots.clear(); + + if (compound.hasKey("AbilityActions")) { + // New unified format + NBTTagList actionList = compound.getTagList("AbilityActions", 10); + for (int i = 0; i < actionList.tagCount(); i++) { + AbilityAction slot = AbilityAction.fromNBT(actionList.getCompoundTagAt(i)); + if (slot != null) { + actionSlots.add(slot); + } + } + } else { + // Legacy migration: read old separate lists + if (compound.hasKey("Abilities")) { + NBTTagList abilityList = compound.getTagList("Abilities", 10); + for (int i = 0; i < abilityList.tagCount(); i++) { + AbilityAction slot = AbilityAction.fromNBT(abilityList.getCompoundTagAt(i)); + if (slot != null) { + actionSlots.add(slot); + } + } + } + if (compound.hasKey("ChainedAbilities")) { + NBTTagList chainList = compound.getTagList("ChainedAbilities", 8); + for (int i = 0; i < chainList.tagCount(); i++) { + String ref = chainList.getStringTagAt(i); + if (ref != null && !ref.isEmpty()) { + actionSlots.add(AbilityAction.chainReference(ref)); + } + } } } } diff --git a/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java b/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java index 2e89d3d49..4afa9dec4 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/GuiNPCAbilities.java @@ -2,7 +2,7 @@ package noppes.npcs.client.gui.advanced; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; -import kamkeel.npcs.controllers.data.ability.AbilitySlot; +import kamkeel.npcs.controllers.data.ability.AbilityAction; import kamkeel.npcs.controllers.data.ability.AbilityVariant; import kamkeel.npcs.network.PacketClient; import kamkeel.npcs.network.packets.request.ability.AbilitiesGetAllPacket; @@ -48,8 +48,8 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Display name to typeId mapping for the available types scroll private final HashMap displayNameToTypeId = new HashMap<>(); - // NPC's current ability slots - private final List npcSlots = new ArrayList<>(); + // NPC's current action slots + private final List npcSlots = new ArrayList<>(); // Settings private boolean abilitiesEnabled = false; @@ -163,9 +163,11 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // On/Off toggle for selected ability (under Load button) if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { Ability selectedAbility = npcSlots.get(selectedAbilityIndex).getAbility(); - GuiNpcButton toggleBtn = new GuiNpcButton(77, centerX, y + 112, 40, 20, - new String[]{"gui.off", "gui.on"}, selectedAbility.isEnabled() ? 1 : 0); - addButton(toggleBtn); + if (selectedAbility != null) { + GuiNpcButton toggleBtn = new GuiNpcButton(77, centerX, y + 112, 40, 20, + new String[]{"gui.off", "gui.on"}, selectedAbility.isEnabled() ? 1 : 0); + addButton(toggleBtn); + } } // Check if selected ability is built-in (non-editable) @@ -220,7 +222,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC variants.get(0).apply(newAbility); } newAbility.setId(UUID.randomUUID().toString()); - npcSlots.add(AbilitySlot.inline(newAbility)); + npcSlots.add(AbilityAction.inline(newAbility)); selectedAbilityIndex = npcSlots.size() - 1; updateNpcAbilitiesList(); selectAbilityByIndex(selectedAbilityIndex); @@ -247,7 +249,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Edit ability if (id == 72) { if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { - AbilitySlot slot = npcSlots.get(selectedAbilityIndex); + AbilityAction slot = npcSlots.get(selectedAbilityIndex); if (slot.isReference()) { // Reference slot - ask clone-and-modify vs modify-parent setSubGui(new SubGuiAbilityEditMode()); @@ -265,7 +267,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Move up (carrot left = earlier in list) if (id == 73) { if (selectedAbilityIndex > 0) { - AbilitySlot temp = npcSlots.get(selectedAbilityIndex); + AbilityAction temp = npcSlots.get(selectedAbilityIndex); npcSlots.set(selectedAbilityIndex, npcSlots.get(selectedAbilityIndex - 1)); npcSlots.set(selectedAbilityIndex - 1, temp); selectedAbilityIndex--; @@ -280,7 +282,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Move down (carrot right = later in list) if (id == 74) { if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size() - 1) { - AbilitySlot temp = npcSlots.get(selectedAbilityIndex); + AbilityAction temp = npcSlots.get(selectedAbilityIndex); npcSlots.set(selectedAbilityIndex, npcSlots.get(selectedAbilityIndex + 1)); npcSlots.set(selectedAbilityIndex + 1, temp); selectedAbilityIndex++; @@ -317,7 +319,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Toggle selected ability on/off if (id == 77) { if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { - AbilitySlot slot = npcSlots.get(selectedAbilityIndex); + AbilityAction slot = npcSlots.get(selectedAbilityIndex); Ability ability = slot.getAbility(); if (ability != null) { boolean enabled = ((GuiNpcButton) guibutton).getValue() == 1; @@ -397,7 +399,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC // Only process NPC ability data, not saved ability preset data // SubGuiAbilityLoad also implements IGuiData and may receive responses // that get forwarded here - ignore those by checking for expected keys - if (!compound.hasKey("Abilities")) { + if (!compound.hasKey("AbilityActions") && !compound.hasKey("Abilities")) { return; } @@ -411,10 +413,12 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC } npcSlots.clear(); - NBTTagList abilityList = compound.getTagList("Abilities", 10); - for (int i = 0; i < abilityList.tagCount(); i++) { - NBTTagCompound abilityNBT = abilityList.getCompoundTagAt(i); - AbilitySlot slot = AbilitySlot.fromNBT(abilityNBT); + // Support both new unified format and legacy format + String tagName = compound.hasKey("AbilityActions") ? "AbilityActions" : "Abilities"; + NBTTagList actionList = compound.getTagList(tagName, 10); + for (int i = 0; i < actionList.tagCount(); i++) { + NBTTagCompound slotNBT = actionList.getCompoundTagAt(i); + AbilityAction slot = AbilityAction.fromNBT(slotNBT); if (slot != null) { npcSlots.add(slot); } @@ -526,7 +530,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC * Broken: "[●] 1. > [Missing: id...]" in red */ private String getAbilityListEntry(int index) { - AbilitySlot slot = npcSlots.get(index); + AbilityAction slot = npcSlots.get(index); Ability ability = slot.getAbility(); if (ability == null) { @@ -579,11 +583,11 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC compound.setInteger("AbilityMinCooldown", minCooldown); compound.setInteger("AbilityMaxCooldown", maxCooldown); - NBTTagList abilityList = new NBTTagList(); - for (AbilitySlot slot : npcSlots) { - abilityList.appendTag(slot.writeNBT()); + NBTTagList actionList = new NBTTagList(); + for (AbilityAction slot : npcSlots) { + actionList.appendTag(slot.writeNBT()); } - compound.setTag("Abilities", abilityList); + compound.setTag("AbilityActions", actionList); PacketClient.sendClient(new AbilitiesNpcSavePacket(compound)); } @@ -595,7 +599,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC public void loadAbility(Ability loadedAbility) { if (loadedAbility != null) { loadedAbility.setId(UUID.randomUUID().toString()); - npcSlots.add(AbilitySlot.inline(loadedAbility)); + npcSlots.add(AbilityAction.inline(loadedAbility)); selectedAbilityIndex = npcSlots.size() - 1; updateNpcAbilitiesList(); selectAbilityByIndex(selectedAbilityIndex); @@ -610,7 +614,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC */ public void loadAbilityReference(String referenceId) { if (referenceId != null && !referenceId.isEmpty()) { - npcSlots.add(AbilitySlot.reference(referenceId)); + npcSlots.add(AbilityAction.abilityReference(referenceId)); selectedAbilityIndex = npcSlots.size() - 1; updateNpcAbilitiesList(); selectAbilityByIndex(selectedAbilityIndex); @@ -652,7 +656,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC @Override public void customScrollDoubleClicked(String selection, GuiCustomScroll scroll) { if (scroll == npcAbilitiesScroll && selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { - AbilitySlot slot = npcSlots.get(selectedAbilityIndex); + AbilityAction slot = npcSlots.get(selectedAbilityIndex); Ability ability = slot.getAbility(); if (ability != null && ability.isBuiltIn()) return; if (slot.isReference()) { @@ -697,7 +701,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC if (newAbility != null) { variantGui.getVariants().get(idx).apply(newAbility); newAbility.setId(UUID.randomUUID().toString()); - npcSlots.add(AbilitySlot.inline(newAbility)); + npcSlots.add(AbilityAction.inline(newAbility)); selectedAbilityIndex = npcSlots.size() - 1; updateNpcAbilitiesList(); selectAbilityByIndex(selectedAbilityIndex); @@ -711,7 +715,7 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC if (mode < 0) return; // cancelled if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { - AbilitySlot slot = npcSlots.get(selectedAbilityIndex); + AbilityAction slot = npcSlots.get(selectedAbilityIndex); if (mode == SubGuiAbilityEditMode.MODE_CLONE_MODIFY) { // Convert reference to inline, then edit if (slot.convertToInline()) { @@ -743,13 +747,13 @@ public class GuiNPCAbilities extends GuiNPCInterface2 implements IScrollData, IC */ public void onAbilitySaved(Ability ability) { if (selectedAbilityIndex >= 0 && selectedAbilityIndex < npcSlots.size()) { - AbilitySlot slot = npcSlots.get(selectedAbilityIndex); + AbilityAction slot = npcSlots.get(selectedAbilityIndex); if (slot.isReference()) { // For reference slots, save back to controller PacketClient.sendClient(new CustomAbilitySavePacket(ability.writeNBT())); } else { // For inline slots, replace with the updated ability - npcSlots.set(selectedAbilityIndex, AbilitySlot.inline(ability)); + npcSlots.set(selectedAbilityIndex, AbilityAction.inline(ability)); } updateNpcAbilitiesList(); save(); diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index f5ffb7028..cecad2494 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -3,6 +3,9 @@ package noppes.npcs.controllers.data; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; import kamkeel.npcs.controllers.data.ability.AbilityPhase; +import kamkeel.npcs.controllers.data.ability.ChainedAbility; +import kamkeel.npcs.controllers.data.ability.ChainedAbilityController; +import kamkeel.npcs.controllers.data.ability.IAbilityAction; import kamkeel.npcs.controllers.data.ability.type.AbilityGuard; import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance; import kamkeel.npcs.network.packets.data.telegraph.TelegraphRemovePacket; @@ -229,6 +232,11 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA } } + @Override + protected void rollChainCooldown(ChainedAbility chain) { + cooldownEndTime = getWorldTime() + chain.getCooldownTicks(); + } + @Override protected void onAbilityComplete() { currentAbility = null; @@ -263,7 +271,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA return; } - if (currentAbility != null && currentAbility.isExecuting()) { + if (chainDelayRemaining > 0 || (currentAbility != null && currentAbility.isExecuting())) { tickCurrentAbility(); // Apply rotation and position locks after ability tick @@ -302,11 +310,28 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA return activateAbility(player, key); } + /** Prefix used to identify chained ability keys in the unlocked list. */ + public static final String CHAIN_PREFIX = "chain:"; + /** - * Activate a specific ability by key. + * Resolve an action key to an IAbilityAction. + * Handles "chain:" prefix for chained abilities, otherwise resolves from AbilityController. + */ + private IAbilityAction resolveActionKey(String key) { + if (key.startsWith(CHAIN_PREFIX)) { + if (ChainedAbilityController.Instance == null) return null; + return ChainedAbilityController.Instance.resolve(key.substring(CHAIN_PREFIX.length())); + } + if (AbilityController.Instance == null) return null; + return AbilityController.Instance.resolveAbility(key); + } + + /** + * Activate a specific ability by key. Keys prefixed with "chain:" are + * treated as chained abilities and resolved from {@link ChainedAbilityController}. * * @param player The player - * @param key The ability key (built-in or preset name) + * @param key The ability key (built-in or preset name), or "chain:name" for chains * @return true if the ability was started */ public boolean activateAbility(EntityPlayer player, String key) { @@ -314,20 +339,32 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA // Can't activate if already executing if (currentAbility != null && currentAbility.isExecuting()) return false; + if (isExecutingChain()) return false; - // Resolve ability from controller - if (AbilityController.Instance == null) return false; - Ability ability = AbilityController.Instance.resolveAbility(key); - if (ability == null) return false; + // Resolve via unified resolver + IAbilityAction action = resolveActionKey(key); + if (action == null) return false; // Check user type allows player - if (!ability.getAllowedBy().allowsPlayer()) return false; - - // Check universal cooldown - if (!ability.isIgnoreCooldown() && isOnCooldown()) return false; + if (!action.getAllowedBy().allowsPlayer()) return false; // Check conditions (skip target-requiring ones) - if (!ability.checkConditionsForPlayer(player)) return false; + if (!action.checkConditionsForPlayer(player)) return false; + + // Dispatch: chain vs ability + if (action.isChain()) { + if (isOnCooldown()) return false; + + currentAbilityKey = key; + currentTarget = null; + return startChain((ChainedAbility) action, null); + } + + // Regular ability activation + Ability ability = (Ability) action; + + // Check universal cooldown (abilities can optionally ignore it) + if (!ability.isIgnoreCooldown() && isOnCooldown()) return false; // Fire extender start hook (e.g., resource cost checks) if (!AbilityController.Instance.fireOnAbilityStart(ability, player, null)) { @@ -777,6 +814,7 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA /** * Remove any unlocked ability keys that can no longer be resolved. * Called during load to clean up references to deleted abilities. + * Supports both regular ability keys and "chain:" prefixed chained ability keys. */ private void validateUnlockedAbilities() { if (AbilityController.Instance == null) return; @@ -784,7 +822,8 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA Iterator it = unlockedAbilities.iterator(); while (it.hasNext()) { String key = it.next(); - if (!AbilityController.Instance.canResolveAbility(key)) { + IAbilityAction resolved = resolveActionKey(key); + if (resolved == null) { it.remove(); LogWriter.info("Removed invalid ability reference from player data: " + key); } diff --git a/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java b/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java index e27f6e075..909b8ba17 100644 --- a/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java +++ b/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java @@ -2,7 +2,7 @@ package noppes.npcs.scripted.wrapper; import kamkeel.npcs.controllers.data.ability.Ability; import kamkeel.npcs.controllers.data.ability.AbilityController; -import kamkeel.npcs.controllers.data.ability.AbilitySlot; +import kamkeel.npcs.controllers.data.ability.AbilityAction; import net.minecraft.entity.EntityLivingBase; import noppes.npcs.DataAbilities; import noppes.npcs.api.ability.IAbility; @@ -81,12 +81,12 @@ public class ScriptDataAbilities implements IDataAbilities { @Override public boolean isAbilityReference(String abilityId) { - List slots = data.getAbilitySlots(); + List slots = data.getAbilityActions(); for (int i = 0; i < slots.size(); i++) { - kamkeel.npcs.controllers.data.ability.AbilitySlot slot = slots.get(i); - if (slot.isReference()) { + AbilityAction slot = slots.get(i); + if (slot.isAbilityReference()) { if (slot.getReferenceId().equals(abilityId)) return true; - } else { + } else if (!slot.isReference()) { Ability a = slot.getAbility(); if (a != null && a.getId().equals(abilityId)) return false; } @@ -96,10 +96,10 @@ public class ScriptDataAbilities implements IDataAbilities { @Override public boolean convertToInline(String abilityId) { - List slots = data.getAbilitySlots(); + List slots = data.getAbilityActions(); for (int i = 0; i < slots.size(); i++) { - kamkeel.npcs.controllers.data.ability.AbilitySlot slot = slots.get(i); - if (slot.isReference() && slot.getReferenceId().equals(abilityId)) { + AbilityAction slot = slots.get(i); + if (slot.isAbilityReference() && slot.getReferenceId().equals(abilityId)) { return data.convertToInline(i); } }