IAbilityAction interface, AbilityAction unified slot, and bug fixes

- Added IAbilityAction interface (internal + API) for unified Ability/ChainedAbility handling
- Created AbilityAction (replaces AbilitySlot) with 3 modes: INLINE_ABILITY, ABILITY_REFERENCE, CHAIN_REFERENCE
- Unified DataAbilities storage from dual lists to single List<AbilityAction>
- Added ChainedAbility, ChainedAbilityEntry, ChainedAbilityController
- Updated PlayerAbilityData with resolveActionKey() for chain: prefix resolution
- Added resolveAction/canResolveAction to AbilityController
- Fixed unsafe cast in DataAbilities.onDamage()
- Fixed NPE in GuiNPCAbilities for chain reference slots
- Fixed NPC chain interrupt double cooldown
- Fixed removeAbility() incorrectly removing chain references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KamandClaude Opus 4.6 committed 2026-02-17 23:22:03 -05:00
1 parent f39d843c70
commit 2f814c1903
17 files changed
+1482 -144

No files matched your search

@@ -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(
@@ -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<String, ChainedAbility> 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<String, ChainedAbility> 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);
}
@@ -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;
}
@@ -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.
* <p>
* 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);
}
}
@@ -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);
}
}
@@ -8,7 +8,10 @@ import net.minecraft.nbt.NBTTagCompound;
* <p>
* 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;
@@ -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.
* <p>
* 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<Condition> conditions = new ArrayList<>();
/** Ordered list of ability entries to execute. */
private List<ChainedAbilityEntry> 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<Condition> getConditions() {
return conditions;
}
public void setConditions(List<Condition> conditions) {
this.conditions = conditions != null ? conditions : new ArrayList<>();
}
public List<ChainedAbilityEntry> getEntries() {
return entries;
}
public void setEntries(List<ChainedAbilityEntry> 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<String> validate() {
List<String> 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);
}
}
}
}
@@ -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<String, ChainedAbility> 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<String, ChainedAbility> 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<String> getNames() {
return new LinkedHashSet<>(chainedAbilities.keySet());
}
public Map<String, ChainedAbility> 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<String, ChainedAbility> 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;
}
}
@@ -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;
}
}
@@ -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).
* <p>
* 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<Condition> 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();
}
@@ -13,5 +13,6 @@ public enum EnumSyncType {
CARPENTRY_RECIPES,
ANVIL_RECIPES,
CUSTOM_EFFECTS,
CUSTOM_ABILITY
CUSTOM_ABILITY,
CHAINED_ABILITY
}
@@ -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();
}
}
@@ -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();
+166 -88
View File
@@ -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<AbilitySlot> abilitySlots = new ArrayList<>();
private List<AbilityAction> 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<IAbilityAction> eligible = new ArrayList<>();
List<Integer> 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<Ability> 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<AbilityAction> 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<Ability> getAbilities() {
List<Ability> 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<AbilitySlot> 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));
}
}
}
}
}
@@ -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<String, String> displayNameToTypeId = new HashMap<>();
// NPC's current ability slots
private final List<AbilitySlot> npcSlots = new ArrayList<>();
// NPC's current action slots
private final List<AbilityAction> 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();
@@ -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<String> 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);
}
@@ -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<AbilitySlot> slots = data.getAbilitySlots();
List<AbilityAction> 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<kamkeel.npcs.controllers.data.ability.AbilitySlot> slots = data.getAbilitySlots();
List<AbilityAction> 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);
}
}