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 d3f6b715b..3f7bf8698 100644 --- a/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java +++ b/src/main/java/kamkeel/npcs/controllers/data/ability/Ability.java @@ -1169,8 +1169,8 @@ public abstract class Ability implements IAbility, IAbilityAction { public void interrupt() { cleanupBurstEntities(); cleanup(); - if (interruptible && (phase == AbilityPhase.WINDUP || phase == AbilityPhase.BURST_DELAY)) { - // Interrupted during windup or burst delay - go to dazed state + if (interruptible && phase == AbilityPhase.WINDUP) { + // Interrupted during windup - go to dazed state phase = AbilityPhase.DAZED; currentTick = 0; } else { @@ -1207,13 +1207,15 @@ public abstract class Ability implements IAbility, IAbilityAction { /** * Check if can be interrupted by this damage source. * Only direct physical hits (not magic, fire, or other indirect damage) can interrupt. - * Interruption only occurs during WINDUP phase - if ability is already active, it completes. + * Interruption only occurs during WINDUP phase (including burst replay windups). + * BURST_DELAY is not interruptible - only the windup animation itself can be interrupted. + * For chained abilities, windUpAll controls whether subsequent entries even have a windup. * * @param source The damage source * @return true if the ability should be interrupted */ public boolean canInterrupt(DamageSource source) { - if (!interruptible || (phase != AbilityPhase.WINDUP && phase != AbilityPhase.BURST_DELAY)) { + if (!interruptible || phase != AbilityPhase.WINDUP) { return false; } if (isInvulnerableForCurrentPhase()) { diff --git a/src/main/java/noppes/npcs/AbstractDataAbilities.java b/src/main/java/noppes/npcs/AbstractDataAbilities.java index 92103a102..f5ba6a8dd 100644 --- a/src/main/java/noppes/npcs/AbstractDataAbilities.java +++ b/src/main/java/noppes/npcs/AbstractDataAbilities.java @@ -35,6 +35,13 @@ import java.util.Set; */ public abstract class AbstractDataAbilities { + /** + * Safety timeout (ticks) for abilities stuck in ACTIVE phase. + * If an ability stays in ACTIVE for longer than this without calling signalCompletion(), + * it is force-completed to prevent permanent stuck state. 30 seconds at 20 TPS. + */ + protected static final int MAX_ACTIVE_PHASE_TICKS = 600; + // ═══════════════════════════════════════════════════════════════════ // SHARED RUNTIME STATE (not saved to NBT) // ═══════════════════════════════════════════════════════════════════ @@ -864,6 +871,16 @@ public abstract class AbstractDataAbilities { if (positionLocked) releaseLockedPosition(); onBurstDelayReleaseLocks(); } + + // Safety timeout: force-complete if stuck in ACTIVE too long. + // Individual ability types should have their own timeouts well below this; + // this is a last-resort failsafe to prevent permanent stuck state. + if (currentAbility != null && currentAbility.getPhase() == AbilityPhase.ACTIVE + && currentAbility.getCurrentTick() > MAX_ACTIVE_PHASE_TICKS) { + currentAbility.cancel(); + handleAbilityCompletion(target); + return; + } break; case BURST_DELAY: diff --git a/src/main/java/noppes/npcs/ServerTickHandler.java b/src/main/java/noppes/npcs/ServerTickHandler.java index 220a0e6ef..12a0f29ce 100644 --- a/src/main/java/noppes/npcs/ServerTickHandler.java +++ b/src/main/java/noppes/npcs/ServerTickHandler.java @@ -50,6 +50,9 @@ public class ServerTickHandler { SyncController.syncEffects(player); ScriptController.Instance.syncClientScripts(player); + // Sync clean ability state to the new client (after beginLogin initializes sync state) + SyncController.syncAbilities(player); + // Send auction notifications on login if (AuctionController.Instance != null) { AuctionController.Instance.onPlayerLogin(player); diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java index 30876a76a..cda7a69cd 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerAbilityData.java @@ -285,6 +285,13 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA @Override protected void onInterruptComplete() { // Player clears state immediately (unlike NPC which ticks through DAZED) + // Roll cooldown before clearing state — NPC rolls during handleAbilityCompletion + // after ticking through DAZED, but the player skips DAZED entirely. + if (!interruptCooldownRolled && currentAbility != null) { + rollCooldown(currentAbility); + } + interruptCooldownRolled = false; + // Stop the dazed animation that was started in interruptCurrentAbility() stopAbilityAnimation(); currentAbility = null; @@ -787,13 +794,70 @@ public class PlayerAbilityData extends AbstractDataAbilities implements IPlayerA /** * Reset ability state after death/respawn. - * Preserves cooldowns (interrupt on death already rolled them) but clears - * active toggles (death should end ongoing effects). + * Preserves cooldowns (the death handler interrupted the ability which rolled cooldown + * via onInterruptComplete) but clears active toggles (death should end ongoing effects). */ public void resetOnRespawn() { onEntityReconstructed(false, true); } + /** + * Reset ability state on login. Clears all transient execution state (current ability, + * locks, chains, cooldowns) that may have leaked from a previous session via the + * PlayerData cache. Toggles are NOT cleared — they are restored from NBT by readFromNBT. + *

+ * Does NOT send packets — called before the client's sync state is initialized. + * The caller must trigger a full ability sync afterwards (e.g. via syncToClient). + */ + public void resetOnLogin() { + // Silently clean up any stale executing ability + if (currentAbility != null && currentAbility.isExecuting()) { + removeTelegraph(currentAbility); + currentAbility.cleanup(); + currentAbility.interrupt(); + stopAbilityAnimation(); + releaseRotationControl(); + releaseLockedPosition(); + } + + // Clear chain and concurrent state + currentChain = null; + chainEntryIndex = -1; + chainDelayRemaining = -1; + interruptConcurrentSlots(); + + // Clear all transient state + currentAbility = null; + currentAbilityKey = null; + currentTarget = null; + lastAbilityActivationTime = -1; + + // Clear cooldowns (fresh start after relog) + cooldownEndTime = 0; + resetAllPerAbilityCooldowns(); + interruptCooldownRolled = false; + + // Reset sync tracking so next sync sends fresh state + lastSyncedFlags = 0; + needsSync = true; + } + + /** + * Clean up ability state on disconnect. Interrupts any executing ability (fires events, + * rolls cooldown) before the player entity goes away, then clears all transient state. + * This ensures scripts receive proper interrupt/complete events and prevents stale state + * from leaking into the next session via the PlayerData cache. + */ + public void resetOnDisconnect() { + // Interrupt with events so scripts know the ability ended + if (currentAbility != null && currentAbility.isExecuting()) { + interruptCurrentAbility(null, 0); + } + // Clear any remaining transient state (cooldowns, chains, locks) + // Don't clear toggles — they were already saved to NBT by this point + onEntityReconstructed(true, false); + } + // ═══════════════════════════════════════════════════════════════════ // DAMAGE HANDLING // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerData.java b/src/main/java/noppes/npcs/controllers/data/PlayerData.java index f37def637..0c575eb77 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerData.java @@ -94,6 +94,10 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { private boolean specialKeyDown = false; public void onLogin() { + // Clear any stale transient ability state that leaked via the PlayerData cache. + // Must happen before the animation check since it may clear ability animation state. + abilityData.resetOnLogin(); + // Handle animation state from previous session AnimationData animationData = this.animationData; if (animationData != null && animationData.isClientAnimating()) { @@ -120,8 +124,9 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { } public void onLogout() { - // Cancel any executing ability and release all locks - abilityData.interruptCurrentAbility(); + // Interrupt executing ability (fires events, rolls cooldown), then clear + // all transient state so nothing leaks via the PlayerData cache. + abilityData.resetOnDisconnect(); this.partyInvites.clear(); this.actionManager.clear(); }