diff --git a/src/api b/src/api index 22bce4b2a..010849fe8 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit 22bce4b2acc9cf9a120a7fdddbb62cc4e567c796 +Subproject commit 010849fe8fb67c0359d7ebdb7bcd5c3c957fe3db diff --git a/src/main/java/kamkeel/npcs/command/AbilityCommand.java b/src/main/java/kamkeel/npcs/command/AbilityCommand.java index 2e91c2962..3c7a0e728 100644 --- a/src/main/java/kamkeel/npcs/command/AbilityCommand.java +++ b/src/main/java/kamkeel/npcs/command/AbilityCommand.java @@ -119,7 +119,8 @@ public class AbilityCommand extends CommandKamkeelBase { return; } - sendResult(sender, "Ability: \u00A7b" + ability.getName()); + sendResult(sender, "Ability: \u00A7b" + ability.getDisplayName()); + sendResult(sender, " Name: \u00A77" + ability.getName()); sendResult(sender, " Type: \u00A7d" + ability.getTypeId()); sendResult(sender, " Cooldown: \u00A7e" + ability.getCooldownTicks() + " ticks"); sendResult(sender, " Wind Up: \u00A7e" + ability.getWindUpTicks() + " ticks"); @@ -190,12 +191,12 @@ public class AbilityCommand extends CommandKamkeelBase { return; } - // Use the ability's canonical ID for storage (registry key for built-in, name for custom) + // Use the ability's canonical ID for storage (registry key for built-in, UUID for custom) String canonicalKey = ability.getId(); if (canonicalKey == null || canonicalKey.isEmpty()) { canonicalKey = abilityKey; // Fallback to user-provided key } - String displayName = ability.getName() != null ? ability.getName() : canonicalKey; + String displayName = ability.getDisplayName(); // Check if ability allows players if (!ability.getAllowedBy().allowsPlayer()) { @@ -234,13 +235,13 @@ public class AbilityCommand extends CommandKamkeelBase { return; } - // Resolve ability to get canonical key + // Resolve ability to get canonical key (registry key for built-in, UUID for custom) Ability ability = AbilityController.Instance.resolveAbility(abilityKey); String canonicalKey = abilityKey; String displayName = abilityKey; if (ability != null) { canonicalKey = ability.getId() != null ? ability.getId() : abilityKey; - displayName = ability.getName() != null ? ability.getName() : canonicalKey; + displayName = ability.getDisplayName(); } // Get player data and lock @@ -286,7 +287,7 @@ public class AbilityCommand extends CommandKamkeelBase { for (int i = 0; i < abilityKeys.length; i++) { String key = abilityKeys[i]; Ability ability = AbilityController.Instance.resolveAbility(key); - String displayName = ability != null && ability.getName() != null ? ability.getName() : key; + String displayName = ability != null ? ability.getDisplayName() : key; String prefix = (i == selected) ? "\u00A7e> " : " "; // Show display name and key if different if (!displayName.equals(key)) { @@ -308,12 +309,17 @@ public class AbilityCommand extends CommandKamkeelBase { /** * Get list of player-usable ability names (for tab completion). - * Uses registry keys for built-in abilities and display names for custom abilities. + * Resolves UUIDs to names so tab completion shows human-readable names. * Used by CommandKamkeel for usage token. */ public static List getPlayerAbilityNames() { - List keys = new ArrayList<>(AbilityController.Instance.getPlayerAbilityKeys()); - Collections.sort(keys); - return keys; + Set keys = AbilityController.Instance.getPlayerAbilityKeys(); + List names = new ArrayList<>(); + for (String key : keys) { + Ability a = AbilityController.Instance.resolveAbility(key); + names.add(a != null ? a.getName() : key); + } + Collections.sort(names); + return names; } } 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 624bea8ae..854766529 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -44,9 +44,10 @@ public abstract class Ability implements IAbility, IAbilityAction { // CONFIGURATION (saved to NBT) // ═══════════════════════════════════════════════════════════════════ - protected String id; - protected String name; - protected String typeId; // e.g., "ability.cnpc.slam" (also used as lang key) + protected String id = ""; // UUID for custom, registry key for built-in + protected String name = ""; // Unique file key / identifier + protected String displayName = ""; // Cosmetic name (falls back to name when empty) + protected String typeId = ""; // e.g., "ability.cnpc.slam" (also used as lang key) // Selection protected int weight = 10; @@ -102,6 +103,23 @@ public abstract class Ability implements IAbility, IAbilityAction { // Cooldown override protected boolean ignoreCooldown = false; + // ═══════════════════════════════════════════════════════════════════ + // BUILT-IN (set in constructor via configureAsBuiltIn, NOT persisted) + // ═══════════════════════════════════════════════════════════════════ + + protected transient boolean builtIn = false; + protected transient String registryKey; + + // ═══════════════════════════════════════════════════════════════════ + // TOGGLE CONFIGURATION (saved to NBT for custom abilities) + // ═══════════════════════════════════════════════════════════════════ + + /** Whether this ability is a toggle (ON/OFF) rather than a phased execution */ + protected boolean toggleable = false; + + /** Whether the toggled-ON state ticks each game tick (e.g., Kaioken drains ki per tick) */ + protected boolean hasActiveToggle = false; + // Configurable potion effects protected List effects = new ArrayList<>(); @@ -205,6 +223,31 @@ public abstract class Ability implements IAbility, IAbilityAction { return false; } + // ═══════════════════════════════════════════════════════════════════ + // TOGGLE CALLBACKS (optional overrides for toggleable abilities) + // ═══════════════════════════════════════════════════════════════════ + + /** + * Called when this toggle ability is turned ON for an entity. + * Override to apply effects (e.g., set a DBC flag, apply a buff). + */ + public void onToggleOn(EntityLivingBase caster) {} + + /** + * Called when this toggle ability is turned OFF for an entity. + * Override to remove effects (e.g., clear a DBC flag, remove a buff). + */ + public void onToggleOff(EntityLivingBase caster) {} + + /** + * Called every tick while this toggle is active (only if hasActiveToggle is true). + * Override for per-tick effects (e.g., ki drain for Kaioken). + * @return true to keep the toggle active, false to auto-deactivate + */ + public boolean onToggleTick(EntityLivingBase caster, int tickCount) { + return true; + } + /** * Whether this ability is ready for auto-completion during burst overlap mode. * Override to delay auto-completion until all staggered projectiles have been fired. @@ -483,6 +526,8 @@ public abstract class Ability implements IAbility, IAbilityAction { // ── General tab ────────────────────────────────────────────── defs.add(FieldDef.stringField("gui.name", this::getName, this::setName) .tab("General")); + defs.add(FieldDef.stringField("gui.displayName", this::getRawDisplayName, this::setDisplayName) + .tab("General")); defs.add(FieldDef.row( FieldDef.intField("ability.weight", this::getWeight, this::setWeight).range(1, 1000), FieldDef.boolField("gui.enabled", this::isEnabled, this::setEnabled) @@ -1119,6 +1164,7 @@ public abstract class Ability implements IAbility, IAbilityAction { NBTTagCompound nbt = new NBTTagCompound(); nbt.setString("id", id); nbt.setString("name", name); + nbt.setString("displayName", displayName); nbt.setString("typeId", typeId); nbt.setInteger("weight", weight); nbt.setBoolean("enabled", enabled); @@ -1150,6 +1196,10 @@ public abstract class Ability implements IAbility, IAbilityAction { nbt.setInteger("allowedBy", allowedBy.ordinal()); nbt.setBoolean("ignoreCooldown", ignoreCooldown); + // Toggle + nbt.setBoolean("toggleable", toggleable); + nbt.setBoolean("hasActiveToggle", hasActiveToggle); + // Burst nbt.setBoolean("burstEnabled", burstEnabled); nbt.setInteger("burstAmount", burstAmount); @@ -1169,10 +1219,12 @@ public abstract class Ability implements IAbility, IAbilityAction { } nbt.setTag("effects", effectList); - // Type-specific - NBTTagCompound typeNBT = new NBTTagCompound(); - writeTypeNBT(typeNBT); - nbt.setTag("typeData", typeNBT); + // Type-specific (skip for built-in abilities with fixed configs) + if (!builtIn) { + NBTTagCompound typeNBT = new NBTTagCompound(); + writeTypeNBT(typeNBT); + nbt.setTag("typeData", typeNBT); + } return nbt; } @@ -1180,6 +1232,7 @@ public abstract class Ability implements IAbility, IAbilityAction { public void readNBT(NBTTagCompound nbt) { id = nbt.getString("id"); name = nbt.getString("name"); + displayName = nbt.getString("displayName"); typeId = nbt.getString("typeId"); weight = nbt.getInteger("weight"); enabled = nbt.getBoolean("enabled"); @@ -1215,6 +1268,10 @@ public abstract class Ability implements IAbility, IAbilityAction { allowedBy = UserType.fromOrdinal(nbt.getInteger("allowedBy")); ignoreCooldown = nbt.getBoolean("ignoreCooldown"); + // Toggle + toggleable = nbt.getBoolean("toggleable"); + hasActiveToggle = nbt.getBoolean("hasActiveToggle"); + // Burst burstEnabled = nbt.getBoolean("burstEnabled"); burstAmount = nbt.getInteger("burstAmount"); @@ -1242,7 +1299,10 @@ public abstract class Ability implements IAbility, IAbilityAction { } } - readTypeNBT(nbt.getCompoundTag("typeData")); + // Type-specific (skip for built-in abilities with fixed configs) + if (!builtIn) { + readTypeNBT(nbt.getCompoundTag("typeData")); + } } // ═══════════════════════════════════════════════════════════════════ @@ -1265,12 +1325,52 @@ public abstract class Ability implements IAbility, IAbilityAction { this.name = name; } + /** + * Get the display name for this ability. + * Returns displayName if set, otherwise falls back to name. + */ + public String getDisplayName() { + return (displayName != null && !displayName.isEmpty()) ? displayName : name; + } + + /** + * Get the raw display name (may be empty). + */ + public String getRawDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName != null ? displayName : ""; + } + public String getTypeId() { return typeId; } public boolean isBuiltIn() { - return false; + return builtIn; + } + + /** + * Get the registry key for built-in abilities. + * Returns null for custom abilities. + */ + public String getRegistryKey() { + return registryKey; + } + + /** + * Configure this ability as a built-in ability with fixed config. + * Sets builtIn flag, assigns registry key, id, name, and derives typeId. + * Call this in the constructor of any ability that should be non-customizable. + */ + protected void configureAsBuiltIn(String registryKey) { + this.builtIn = true; + this.registryKey = registryKey; + this.id = registryKey; + this.name = registryKey; + this.typeId = "ability." + registryKey.replace(':', '.'); } public Ability deepCopy() { @@ -1737,6 +1837,22 @@ public abstract class Ability implements IAbility, IAbilityAction { this.ignoreCooldown = ignoreCooldown; } + public boolean isToggleable() { + return toggleable; + } + + public void setToggleable(boolean toggleable) { + this.toggleable = toggleable; + } + + public boolean hasActiveToggle() { + return hasActiveToggle; + } + + public void setHasActiveToggle(boolean hasActiveToggle) { + this.hasActiveToggle = hasActiveToggle; + } + public List getEffects() { return effects; } 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 23a718430..2af31f73a 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/AbilityController.java @@ -33,7 +33,8 @@ public class AbilityController implements IAbilityHandler { // ── Core Registries ────────────────────────────────────────────────────── private final Map> abilityTypes = new LinkedHashMap<>(); private final Map builtAbilities = new LinkedHashMap<>(); - private final Map customAbilities = new LinkedHashMap<>(); + private final Map customAbilities = new LinkedHashMap<>(); // name → Ability + private final Map customAbilitiesById = new LinkedHashMap<>(); // UUID → Ability // ── Extension Points ───────────────────────────────────────────────────── private final Map> externalVariants = new LinkedHashMap<>(); @@ -162,6 +163,7 @@ public class AbilityController implements IAbilityHandler { public void load() { customAbilities.clear(); + customAbilitiesById.clear(); int migrated = 0; File dir = getDir(); @@ -177,6 +179,13 @@ public class AbilityController implements IAbilityHandler { Ability ability = fromNBT(nbt); if (ability == null) continue; + // Skip built-in abilities that were erroneously saved as custom files + if (ability.isBuiltIn()) { + file.delete(); + LogWriter.info("Removed stale built-in ability file: " + filename); + continue; + } + // Legacy migration: UUID-named files get renamed to name-based if (UUID_PATTERN.matcher(key).matches()) { String name = ability.getName(); @@ -202,8 +211,24 @@ public class AbilityController implements IAbilityHandler { migrated++; } - ability.setId(key); + // Ensure ability has a UUID; generate one for legacy abilities + String uuid = ability.getId(); + if (uuid == null || uuid.isEmpty() || uuid.equals(key)) { + // Legacy: id was the name or empty — generate a real UUID + uuid = UUID.randomUUID().toString(); + ability.setId(uuid); + // Re-save file with the new UUID + try { + NBTJsonUtil.SaveFile(file, ability.writeNBT()); + } catch (Exception ex) { + LogWriter.error("Failed to save UUID for ability: " + key, ex); + } + migrated++; + } + + ability.setName(key); customAbilities.put(key, ability); + customAbilitiesById.put(uuid, ability); } catch (Exception e) { LogWriter.error("Error loading custom ability: " + file.getAbsolutePath(), e); } @@ -212,7 +237,7 @@ public class AbilityController implements IAbilityHandler { customAbilityRevision++; if (migrated > 0) { - LogWriter.info("Migrated " + migrated + " UUID-named abilities to name-based files"); + LogWriter.info("Migrated " + migrated + " abilities (UUID-named files or missing UUIDs)"); } LogWriter.info("Loaded " + customAbilities.size() + " custom abilities"); } @@ -223,42 +248,50 @@ public class AbilityController implements IAbilityHandler { public boolean saveCustomAbility(Ability ability) { if (ability == null) return false; + if (ability.isBuiltIn()) return false; String name = ability.getName(); if (name == null || name.isEmpty()) return false; - // Ensure unique name for new abilities (no existing id) - String oldName = ability.getId(); - boolean isNew = oldName == null || oldName.isEmpty(); + String uuid = ability.getId(); + boolean isNew = (uuid == null || uuid.isEmpty()); + if (isNew) { + // Generate UUID for new ability + uuid = UUID.randomUUID().toString(); + ability.setId(uuid); + + // Ensure unique name while (hasCustomAbilityName(name)) { name = name + "_"; } ability.setName(name); + } else { + // Existing ability — check for rename via UUID lookup + Ability existing = customAbilitiesById.get(uuid); + if (existing != null) { + String oldName = existing.getName(); + if (!oldName.equals(name)) { + // Name changed! Ensure new name is unique (excluding self) + String testName = name; + while (customAbilities.containsKey(testName) && !testName.equals(oldName)) { + testName = testName + "_"; + } + if (!testName.equals(name)) { + name = testName; + ability.setName(name); + } + + // Delete old file and remove old map entry + File oldFile = new File(getDir(), oldName + ".json"); + if (oldFile.exists()) { + oldFile.delete(); + } + customAbilities.remove(oldName); + } + } } - // Handle rename: old id (previous name) differs from current name - if (!isNew && !oldName.equals(name)) { - // Check uniqueness of new name (excluding the entry being renamed) - String testName = name; - while (customAbilities.containsKey(testName) && !testName.equals(oldName)) { - testName = testName + "_"; - } - if (!testName.equals(name)) { - name = testName; - ability.setName(name); - } - - // Delete old file and remove old map entry - File oldFile = new File(getDir(), oldName + ".json"); - if (oldFile.exists()) { - oldFile.delete(); - } - customAbilities.remove(oldName); - } - - ability.setId(name); - File dir = getDir(); File fileNew = new File(dir, name + ".json_new"); File fileCurrent = new File(dir, name + ".json"); @@ -276,8 +309,9 @@ public class AbilityController implements IAbilityHandler { } customAbilities.put(name, ability); + customAbilitiesById.put(uuid, ability); customAbilityRevision++; - LogWriter.info("Saved custom ability: " + name); + LogWriter.info("Saved custom ability: " + name + " [" + uuid + "]"); SyncController.syncAllCustomAbilities(); return true; } catch (Exception e) { @@ -292,6 +326,12 @@ public class AbilityController implements IAbilityHandler { Ability removed = customAbilities.remove(name); if (removed == null) return false; + // Also remove from UUID index + String uuid = removed.getId(); + if (uuid != null && !uuid.isEmpty()) { + customAbilitiesById.remove(uuid); + } + File dir = getDir(); File file = new File(dir, name + ".json"); if (file.exists()) { @@ -318,7 +358,14 @@ public class AbilityController implements IAbilityHandler { public void setCustomAbilities(Map synced) { customAbilities.clear(); + customAbilitiesById.clear(); customAbilities.putAll(synced); + for (Ability ability : synced.values()) { + String uuid = ability.getId(); + if (uuid != null && !uuid.isEmpty()) { + customAbilitiesById.put(uuid, ability); + } + } customAbilityRevision++; } @@ -341,6 +388,10 @@ public class AbilityController implements IAbilityHandler { public Ability resolveAbility(String key) { if (key == null || key.isEmpty()) return null; + // Custom: UUID lookup (most common for persistent references) + Ability byUuid = customAbilitiesById.get(key); + if (byUuid != null) return byUuid.deepCopy(); + // Built-in: exact name Ability builtIn = builtAbilities.get(key); if (builtIn != null) return builtIn.deepCopy(); @@ -359,7 +410,7 @@ public class AbilityController implements IAbilityHandler { } } - // Custom: exact name + // Custom: exact name (fallback for legacy references) Ability custom = customAbilities.get(key); if (custom != null) return custom.deepCopy(); @@ -381,11 +432,11 @@ public class AbilityController implements IAbilityHandler { keys.add(id); } } - // Custom abilities keyed by name + // Custom abilities keyed by UUID (stable references) for (Ability ability : customAbilities.values()) { - String name = ability.getName(); - if (name != null && !name.isEmpty()) { - keys.add(name); + String id = ability.getId(); + if (id != null && !id.isEmpty()) { + keys.add(id); } } return keys; @@ -401,12 +452,12 @@ public class AbilityController implements IAbilityHandler { } } } - // Custom abilities keyed by name + // Custom abilities keyed by UUID (stable references) for (Ability ability : customAbilities.values()) { if (ability.getAllowedBy().allowsPlayer()) { - String name = ability.getName(); - if (name != null && !name.isEmpty()) { - keys.add(name); + String id = ability.getId(); + if (id != null && !id.isEmpty()) { + keys.add(id); } } } @@ -414,7 +465,16 @@ public class AbilityController implements IAbilityHandler { } public boolean hasAbility(String key) { - return builtAbilities.containsKey(key) || customAbilities.containsKey(key); + return builtAbilities.containsKey(key) || customAbilities.containsKey(key) + || customAbilitiesById.containsKey(key); + } + + public Ability getCustomAbilityByUUID(String uuid) { + return customAbilitiesById.get(uuid); + } + + public Ability getCustomAbilityByName(String name) { + return customAbilities.get(name); } /** @@ -424,6 +484,9 @@ public class AbilityController implements IAbilityHandler { public boolean canResolveAbility(String key) { if (key == null || key.isEmpty()) return false; + // Custom: UUID lookup (most common for persistent references) + if (customAbilitiesById.containsKey(key)) return true; + // Built-in: exact name if (builtAbilities.containsKey(key)) return true; @@ -437,7 +500,7 @@ public class AbilityController implements IAbilityHandler { if (ability.getId() != null && ability.getId().equalsIgnoreCase(key)) return true; } - // Custom: exact name + // Custom: exact name (fallback for legacy references) if (customAbilities.containsKey(key)) return true; // Custom: case-insensitive name diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/BuiltInAbility.java b/src/main/java/kamkeel/npcs/controllers/data/ability/BuiltInAbility.java deleted file mode 100644 index 36344e9e5..000000000 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/BuiltInAbility.java +++ /dev/null @@ -1,61 +0,0 @@ -package kamkeel.npcs.controllers.data.ability; - -import net.minecraft.nbt.NBTTagCompound; - -/** - * Abstract base class for built-in (non-customizable) abilities. - * Built-in abilities are registered by key, have fixed configurations, - * and cannot be modified through the preset GUI system. - *

- * Examples: simple utility abilities like Jump, Sprint, etc. - * Third-party mods can extend this class to create their own built-in abilities. - */ -public abstract class BuiltInAbility extends Ability { - - /** - * The registry key used to look up this ability (e.g., "cnpc:jump", "dbc:ki_blast"). - */ - private final String registryKey; - - public BuiltInAbility(String registryKey) { - this.registryKey = registryKey; - this.id = registryKey; - this.name = registryKey; - // Convert "namespace:name" to "ability.namespace.name" for GUI filter compatibility - this.typeId = "ability." + registryKey.replace(':', '.'); - } - - @Override - public boolean isBuiltIn() { - return true; - } - - /** - * Get the registry key for this built-in ability. - */ - public String getRegistryKey() { - return registryKey; - } - - /** - * Built-in abilities do not write type-specific NBT (they have fixed configs). - */ - @Override - public void writeTypeNBT(NBTTagCompound nbt) { - // No-op: built-in abilities have fixed configurations - } - - /** - * Built-in abilities do not read type-specific NBT. - */ - @Override - public void readTypeNBT(NBTTagCompound nbt) { - // No-op: built-in abilities have fixed configurations - } - - /** - * Get the duration of the active phase in ticks. - * Built-in abilities must define their own active duration. - */ - public abstract int getActiveDurationTicks(); -} diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java index 1ba1a204e..dd45de17d 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbility.java @@ -22,7 +22,9 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { // CONFIGURATION (saved to NBT) // ═══════════════════════════════════════════════════════════════════ - private String name = ""; + private String id = ""; // UUID (stable persistent reference) + private String name = ""; // Unique file key / identifier + private String displayName = ""; // Cosmetic name (falls back to name when empty) private boolean enabled = true; @@ -60,6 +62,14 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { // GETTERS / SETTERS // ═══════════════════════════════════════════════════════════════════ + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id != null ? id : ""; + } + public String getName() { return name; } @@ -68,6 +78,22 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { this.name = name != null ? name : ""; } + /** + * Get the display name for this chained ability. + * Returns displayName if set, otherwise falls back to name. + */ + public String getDisplayName() { + return (displayName != null && !displayName.isEmpty()) ? displayName : name; + } + + public String getRawDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName != null ? displayName : ""; + } + public boolean isEnabled() { return enabled; } @@ -288,7 +314,9 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { public NBTTagCompound writeNBT() { NBTTagCompound nbt = new NBTTagCompound(); + nbt.setString("Id", id); nbt.setString("Name", name); + nbt.setString("DisplayName", displayName); nbt.setBoolean("Enabled", enabled); nbt.setInteger("Weight", weight); nbt.setBoolean("WindUpAll", windUpAll); @@ -315,7 +343,9 @@ public class ChainedAbility implements IChainedAbility, IAbilityAction { } public void readNBT(NBTTagCompound nbt) { + id = nbt.getString("Id"); name = nbt.getString("Name"); + displayName = nbt.getString("DisplayName"); enabled = !nbt.hasKey("Enabled") || nbt.getBoolean("Enabled"); weight = nbt.hasKey("Weight") ? nbt.getInteger("Weight") : 10; windUpAll = !nbt.hasKey("WindUpAll") || nbt.getBoolean("WindUpAll"); diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java index 05819dea4..775ea0d7e 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ChainedAbilityController.java @@ -12,13 +12,15 @@ 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. + * JSON file storage in {@code abilities/chained/}, dual-index by name and UUID, + * revision tracking for cache invalidation, and client sync. */ public class ChainedAbilityController { public static ChainedAbilityController Instance = new ChainedAbilityController(); - private final Map chainedAbilities = new LinkedHashMap<>(); + private final Map chainedAbilities = new LinkedHashMap<>(); // name → ChainedAbility + private final Map chainedAbilitiesById = new LinkedHashMap<>(); // UUID → ChainedAbility /** Incremented on any change; used for cache invalidation. */ private int revision = 0; @@ -32,8 +34,33 @@ public class ChainedAbilityController { */ public void load() { chainedAbilities.clear(); + chainedAbilitiesById.clear(); + int migrated = 0; File dir = getDir(); + + // Legacy migration: move files from old chained_abilities/ directory + File legacyDir = new File(CustomNpcs.getWorldSaveDirectory(), "chained_abilities"); + if (legacyDir.exists() && legacyDir.isDirectory()) { + File[] legacyFiles = legacyDir.listFiles(); + if (legacyFiles != null) { + for (File legacyFile : legacyFiles) { + if (!legacyFile.isFile() || !legacyFile.getName().endsWith(".json")) continue; + File dest = new File(dir, legacyFile.getName()); + if (!dest.exists()) { + if (legacyFile.renameTo(dest)) { + LogWriter.info("Migrated chained ability file: chained_abilities/" + legacyFile.getName() + " -> abilities/chained/" + legacyFile.getName()); + migrated++; + } + } + } + // Remove legacy directory if empty + if (legacyDir.list() != null && legacyDir.list().length == 0) { + legacyDir.delete(); + } + } + } + File[] files = dir.exists() ? dir.listFiles() : null; if (files != null) { for (File file : files) { @@ -46,9 +73,24 @@ public class ChainedAbilityController { ChainedAbility chain = new ChainedAbility(); chain.readNBT(nbt); - // Use filename as authoritative key + // Ensure chain has a UUID; generate one for legacy entries + String uuid = chain.getId(); + if (uuid == null || uuid.isEmpty() || uuid.equals(key)) { + uuid = UUID.randomUUID().toString(); + chain.setId(uuid); + // Re-save file with the new UUID + try { + NBTJsonUtil.SaveFile(file, chain.writeNBT()); + } catch (Exception ex) { + LogWriter.error("Failed to save UUID for chained ability: " + key, ex); + } + migrated++; + } + + // Use filename as authoritative name key chain.setName(key); chainedAbilities.put(key, chain); + chainedAbilitiesById.put(uuid, chain); } catch (Exception e) { LogWriter.error("Error loading chained ability: " + file.getAbsolutePath(), e); } @@ -56,11 +98,14 @@ public class ChainedAbilityController { } revision++; + if (migrated > 0) { + LogWriter.info("Migrated " + migrated + " chained abilities (legacy dir or missing UUIDs)"); + } LogWriter.info("Loaded " + chainedAbilities.size() + " chained abilities"); } /** - * Save a chained ability to disk. Handles renames and uniqueness. + * Save a chained ability to disk. Handles UUID assignment, renames, and uniqueness. */ public boolean save(ChainedAbility chain) { if (chain == null) return false; @@ -68,6 +113,45 @@ public class ChainedAbilityController { String name = chain.getName(); if (name == null || name.isEmpty()) return false; + String uuid = chain.getId(); + boolean isNew = (uuid == null || uuid.isEmpty()); + + if (isNew) { + // Generate UUID for new chain + uuid = UUID.randomUUID().toString(); + chain.setId(uuid); + + // Ensure unique name + while (hasName(name)) { + name = name + "_"; + } + chain.setName(name); + } else { + // Existing chain — check for rename via UUID lookup + ChainedAbility existing = chainedAbilitiesById.get(uuid); + if (existing != null) { + String oldName = existing.getName(); + if (!oldName.equals(name)) { + // Name changed! Ensure new name is unique (excluding self) + String testName = name; + while (chainedAbilities.containsKey(testName) && !testName.equals(oldName)) { + testName = testName + "_"; + } + if (!testName.equals(name)) { + name = testName; + chain.setName(name); + } + + // Delete old file and remove old map entry + File oldFile = new File(getDir(), oldName + ".json"); + if (oldFile.exists()) { + oldFile.delete(); + } + chainedAbilities.remove(oldName); + } + } + } + File dir = getDir(); File fileNew = new File(dir, name + ".json_new"); File fileCurrent = new File(dir, name + ".json"); @@ -85,8 +169,9 @@ public class ChainedAbilityController { } chainedAbilities.put(name, chain); + chainedAbilitiesById.put(uuid, chain); revision++; - LogWriter.info("Saved chained ability: " + name); + LogWriter.info("Saved chained ability: " + name + " [" + uuid + "]"); SyncController.syncAllChainedAbilities(); return true; } catch (Exception e) { @@ -104,6 +189,12 @@ public class ChainedAbilityController { ChainedAbility removed = chainedAbilities.remove(name); if (removed == null) return false; + // Also remove from UUID index + String uuid = removed.getId(); + if (uuid != null && !uuid.isEmpty()) { + chainedAbilitiesById.remove(uuid); + } + File dir = getDir(); File file = new File(dir, name + ".json"); if (file.exists()) { @@ -121,19 +212,23 @@ public class ChainedAbilityController { // ═══════════════════════════════════════════════════════════════════ /** - * Resolve a chained ability by name, returning a deep copy. - * Tries exact match first, then case-insensitive. + * Resolve a chained ability by key, returning a deep copy. + * Checks UUID first, then exact name, then case-insensitive name. */ - public ChainedAbility resolve(String name) { - if (name == null || name.isEmpty()) return null; + public ChainedAbility resolve(String key) { + if (key == null || key.isEmpty()) return null; - // Exact match - ChainedAbility chain = chainedAbilities.get(name); + // UUID lookup (most common for persistent references) + ChainedAbility byId = chainedAbilitiesById.get(key); + if (byId != null) return byId.deepCopy(); + + // Exact name match + ChainedAbility chain = chainedAbilities.get(key); if (chain != null) return chain.deepCopy(); - // Case-insensitive match + // Case-insensitive name match for (Map.Entry entry : chainedAbilities.entrySet()) { - if (entry.getKey().equalsIgnoreCase(name)) { + if (entry.getKey().equalsIgnoreCase(key)) { return entry.getValue().deepCopy(); } } @@ -142,15 +237,17 @@ public class ChainedAbilityController { } /** - * Check if a name can be resolved without creating a deep copy. + * Check if a key can be resolved without creating a deep copy. */ - public boolean canResolve(String name) { - if (name == null || name.isEmpty()) return false; + public boolean canResolve(String key) { + if (key == null || key.isEmpty()) return false; - if (chainedAbilities.containsKey(name)) return true; + if (chainedAbilitiesById.containsKey(key)) return true; - for (String key : chainedAbilities.keySet()) { - if (key.equalsIgnoreCase(name)) return true; + if (chainedAbilities.containsKey(key)) return true; + + for (String name : chainedAbilities.keySet()) { + if (name.equalsIgnoreCase(key)) return true; } return false; @@ -164,6 +261,10 @@ public class ChainedAbilityController { return chainedAbilities.get(name); } + public ChainedAbility getByUUID(String uuid) { + return chainedAbilitiesById.get(uuid); + } + public Set getNames() { return new LinkedHashSet<>(chainedAbilities.keySet()); } @@ -176,6 +277,10 @@ public class ChainedAbilityController { return chainedAbilities.containsKey(name); } + public boolean hasKey(String key) { + return chainedAbilities.containsKey(key) || chainedAbilitiesById.containsKey(key); + } + public int getRevision() { return revision; } @@ -189,7 +294,14 @@ public class ChainedAbilityController { */ public void setChainedAbilities(Map synced) { chainedAbilities.clear(); + chainedAbilitiesById.clear(); chainedAbilities.putAll(synced); + for (ChainedAbility chain : synced.values()) { + String uuid = chain.getId(); + if (uuid != null && !uuid.isEmpty()) { + chainedAbilitiesById.put(uuid, chain); + } + } revision++; } @@ -198,7 +310,7 @@ public class ChainedAbilityController { // ═══════════════════════════════════════════════════════════════════ private File getDir() { - File dir = new File(CustomNpcs.getWorldSaveDirectory(), "chained_abilities"); + File dir = new File(CustomNpcs.getWorldSaveDirectory(), "abilities" + File.separator + "chained"); if (!dir.exists()) { dir.mkdirs(); } diff --git a/src/main/java/kamkeel/npcs/controllers/data/ability/ToggleEntry.java b/src/main/java/kamkeel/npcs/controllers/data/ability/ToggleEntry.java new file mode 100644 index 000000000..e08350601 --- /dev/null +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/ToggleEntry.java @@ -0,0 +1,27 @@ +package kamkeel.npcs.controllers.data.ability; + +/** + * Tracks the state of a single active toggle for an entity. + * Stored in AbstractDataAbilities.activeToggles map. + */ +public class ToggleEntry { + private final Ability ability; + private int tickCount; + + public ToggleEntry(Ability ability) { + this.ability = ability; + this.tickCount = 0; + } + + public Ability getAbility() { + return ability; + } + + public int getTickCount() { + return tickCount; + } + + public void incrementTick() { + tickCount++; + } +} diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index 11ff57104..89c0b4f51 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -5,13 +5,14 @@ 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.ability.ToggleEntry; import kamkeel.npcs.controllers.data.telegraph.TelegraphInstance; import net.minecraft.entity.EntityLivingBase; import net.minecraft.util.DamageSource; import noppes.npcs.controllers.AnimationController; import noppes.npcs.controllers.data.Animation; -import java.util.List; +import java.util.*; /** * Shared base class for NPC and Player ability data management. @@ -197,6 +198,140 @@ public abstract class AbstractDataAbilities { return currentChain; } + // ═══════════════════════════════════════════════════════════════════ + // TOGGLE STATE (active toggles, independent of currentAbility) + // ═══════════════════════════════════════════════════════════════════ + + /** Currently active toggles. Key = ability key, Value = toggle entry with tick counter. */ + protected Map activeToggles = new LinkedHashMap<>(); + + /** + * Toggle an ability ON or OFF. If currently on, turns off; if off, turns on. + * @param key The ability key (e.g., "npcdbc:ki_fist") + * @return true if the toggle is now ON, false if OFF + */ + public boolean toggleAbility(String key) { + if (activeToggles.containsKey(key)) { + deactivateToggle(key); + return false; + } else { + return activateToggle(key); + } + } + + /** + * Set a toggle to a specific state. + * @param key The ability key + * @param on true to activate, false to deactivate + */ + public void setAbilityToggled(String key, boolean on) { + if (on && !activeToggles.containsKey(key)) { + activateToggle(key); + } else if (!on && activeToggles.containsKey(key)) { + deactivateToggle(key); + } + } + + /** + * Check if a toggle ability is currently active. + */ + public boolean isAbilityToggled(String key) { + return activeToggles.containsKey(key); + } + + /** + * Get all active toggle keys. + */ + public Set getActiveToggleKeys() { + return new LinkedHashSet<>(activeToggles.keySet()); + } + + private boolean activateToggle(String key) { + Ability ability = AbilityController.Instance != null + ? AbilityController.Instance.resolveAbility(key) : null; + if (ability == null || !ability.isToggleable()) return false; + + ToggleEntry entry = new ToggleEntry(ability); + activeToggles.put(key, entry); + ability.onToggleOn(getEntity()); + onToggleStateChanged(key, true); + return true; + } + + private void deactivateToggle(String key) { + ToggleEntry entry = activeToggles.remove(key); + if (entry != null) { + entry.getAbility().onToggleOff(getEntity()); + onToggleStateChanged(key, false); + } + } + + /** + * Tick all active toggles. Call from tick() each game tick. + * Toggles with hasActiveToggle=true get their onToggleTick() called. + * If onToggleTick returns false, the toggle is auto-deactivated. + */ + protected void tickActiveToggles() { + if (activeToggles.isEmpty()) return; + + EntityLivingBase entity = getEntity(); + List toRemove = null; + + for (Map.Entry mapEntry : activeToggles.entrySet()) { + ToggleEntry entry = mapEntry.getValue(); + entry.incrementTick(); + + if (entry.getAbility().hasActiveToggle()) { + if (!entry.getAbility().onToggleTick(entity, entry.getTickCount())) { + if (toRemove == null) toRemove = new ArrayList<>(); + toRemove.add(mapEntry.getKey()); + } + } + } + + if (toRemove != null) { + for (String key : toRemove) { + ToggleEntry entry = activeToggles.remove(key); + if (entry != null) { + entry.getAbility().onToggleOff(entity); + onToggleStateChanged(key, false); + } + } + } + } + + /** Hook for subclass to react to toggle state changes (sync packets, script events). */ + protected void onToggleStateChanged(String key, boolean active) {} + + /** + * Clear all active toggles (e.g., on death/reset). + * Calls onToggleOff for each active toggle. + */ + protected void clearActiveToggles() { + if (activeToggles.isEmpty()) return; + EntityLivingBase entity = getEntity(); + for (ToggleEntry entry : activeToggles.values()) { + entry.getAbility().onToggleOff(entity); + } + activeToggles.clear(); + } + + /** + * Add a toggle entry directly without calling onToggleOn. + * Used for client-side sync and NBT restoration. + */ + public void setToggleEntryDirect(String key, boolean active) { + if (active) { + Ability ability = AbilityController.Instance != null + ? AbilityController.Instance.resolveAbility(key) : null; + if (ability != null && ability.isToggleable()) { + activeToggles.put(key, new ToggleEntry(ability)); + } + } else { + activeToggles.remove(key); + } + } + // ═══════════════════════════════════════════════════════════════════ // COOLDOWN MANAGEMENT // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/java/noppes/npcs/DataAbilities.java b/src/main/java/noppes/npcs/DataAbilities.java index 52a086cce..af623b741 100644 --- a/src/main/java/noppes/npcs/DataAbilities.java +++ b/src/main/java/noppes/npcs/DataAbilities.java @@ -14,6 +14,7 @@ import kamkeel.npcs.network.packets.data.telegraph.TelegraphSpawnPacket; import net.minecraft.entity.EntityLivingBase; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.nbt.NBTTagList; +import net.minecraft.nbt.NBTTagString; import net.minecraft.util.DamageSource; import noppes.npcs.controllers.data.Animation; import noppes.npcs.entity.EntityNPCInterface; @@ -318,6 +319,9 @@ public class DataAbilities extends AbstractDataAbilities { if (positionLocked) releaseLockedPosition(); } + // Tick active toggles (independent of currentAbility) + tickActiveToggles(); + // Tick current ability if executing, or tick chain delay between entries if (chainDelayRemaining > 0 || (currentAbility != null && currentAbility.isExecuting())) { tickCurrentAbility(); @@ -699,6 +703,7 @@ public class DataAbilities extends AbstractDataAbilities { */ public void reset() { stopCurrentAbility(); + clearActiveToggles(); // Roll cooldown so NPC doesn't immediately attack after reset rollCooldownOnReset(); @@ -1038,6 +1043,13 @@ public class DataAbilities extends AbstractDataAbilities { } compound.setTag("AbilityActions", actionList); + // Active toggles + NBTTagList toggleList = new NBTTagList(); + for (String key : activeToggles.keySet()) { + toggleList.appendTag(new NBTTagString(key)); + } + compound.setTag("ActiveToggles", toggleList); + return compound; } @@ -1078,5 +1090,15 @@ public class DataAbilities extends AbstractDataAbilities { } } } + + // Active toggles - restore state directly (no onToggleOn callback during load) + activeToggles.clear(); + if (compound.hasKey("ActiveToggles")) { + NBTTagList toggleNbt = compound.getTagList("ActiveToggles", 8); // 8 = TAG_STRING + for (int i = 0; i < toggleNbt.tagCount(); i++) { + String key = toggleNbt.getStringTagAt(i); + setToggleEntryDirect(key, true); + } + } } } diff --git a/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java b/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java index d016e7c77..9378e606b 100644 --- a/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java +++ b/src/main/java/noppes/npcs/client/gui/global/GuiNpcManageAbilities.java @@ -474,9 +474,12 @@ public class GuiNpcManageAbilities extends GuiAbilityInterface private boolean hasDuplicateName(Ability ability) { String name = ability.getName(); if (name == null || name.isEmpty()) return false; - String oldName = ability.getId(); - // A duplicate exists if the name is already in use and it's not the same ability being edited - return customData.containsKey(name) && !name.equals(oldName); + if (!customData.containsKey(name)) return false; + // Name exists — check if it belongs to a different ability (by UUID) + String uuid = ability.getId(); + if (uuid == null || uuid.isEmpty()) return true; // New ability with conflicting name + Ability existing = AbilityController.Instance.getCustomAbilityByName(name); + return existing == null || !uuid.equals(existing.getId()); } @Override diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index cecad2494..1a1751a30 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -271,6 +271,9 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA return; } + // Tick active toggles (independent of currentAbility) + tickActiveToggles(); + if (chainDelayRemaining > 0 || (currentAbility != null && currentAbility.isExecuting())) { tickCurrentAbility(); @@ -422,6 +425,11 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA } } + @Override + protected void onToggleStateChanged(String key, boolean active) { + syncToClient(); + } + // ═══════════════════════════════════════════════════════════════════ // SELECTION // ═══════════════════════════════════════════════════════════════════ @@ -787,6 +795,13 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA compound.setTag("PlayerAbilities", list); compound.setInteger("PlayerAbilitySelected", selectedIndex); compound.setBoolean("AbilityAnimating", playingAbilityAnimation); + + // Active toggles + NBTTagList toggleList = new NBTTagList(); + for (String key : activeToggles.keySet()) { + toggleList.appendTag(new NBTTagString(key)); + } + compound.setTag("ActiveToggles", toggleList); } public void readFromNBT(NBTTagCompound compound) { @@ -809,6 +824,16 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA selectedIndex = Math.max(0, unlockedAbilities.size() - 1); } playingAbilityAnimation = compound.getBoolean("AbilityAnimating"); + + // Active toggles - restore state directly (no onToggleOn callback during load) + activeToggles.clear(); + if (compound.hasKey("ActiveToggles")) { + NBTTagList toggleNbt = compound.getTagList("ActiveToggles", 8); // 8 = TAG_STRING + for (int i = 0; i < toggleNbt.tagCount(); i++) { + String key = toggleNbt.getStringTagAt(i); + setToggleEntryDirect(key, true); + } + } } /** diff --git a/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java b/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java index 909b8ba17..e08055018 100644 --- a/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java +++ b/src/main/java/noppes/npcs/scripted/wrapper/ScriptDataAbilities.java @@ -61,7 +61,9 @@ public class ScriptDataAbilities implements IDataAbilities { if (!ability.getAllowedBy().allowsNpc()) { return; // Ability is PLAYER_ONLY } - data.addAbilityReference(key); + // Store by UUID/registry key for stable reference + String canonicalKey = ability.getId() != null ? ability.getId() : key; + data.addAbilityReference(canonicalKey); } @Override diff --git a/src/main/java/noppes/npcs/scripted/wrapper/ScriptPlayerAbilityData.java b/src/main/java/noppes/npcs/scripted/wrapper/ScriptPlayerAbilityData.java index c1de83878..42ed3372e 100644 --- a/src/main/java/noppes/npcs/scripted/wrapper/ScriptPlayerAbilityData.java +++ b/src/main/java/noppes/npcs/scripted/wrapper/ScriptPlayerAbilityData.java @@ -36,7 +36,7 @@ public class ScriptPlayerAbilityData implements IPlayerAbilityData { if (!ability.getAllowedBy().allowsPlayer()) { return; // Ability is NPC-only } - // Use canonical key (registry key for built-in, name for custom) + // Use canonical key (registry key for built-in, UUID for custom) String canonicalKey = ability.getId() != null ? ability.getId() : key; data.unlockAbility(canonicalKey); }