From a464c3c247dd5797c7a81e23e65726ca3233c3e1 Mon Sep 17 00:00:00 2001 From: fretuks Date: Thu, 27 Aug 2026 14:30:33 +0200 Subject: [PATCH] fix more issues --- README.md | 19 ++- .../net/fretux/ailments/api/AilmentApi.java | 149 +++++++++++------- .../fretux/ailments/compat/AscendCompat.java | 65 +++++--- .../fretux/ailments/event/AilmentEvents.java | 50 ++++-- .../ailments/util/ControlEffectHelper.java | 88 ++++++++--- .../ailments/util/EffectSourceUtil.java | 6 + .../ailments/util/HemorrhageTracker.java | 4 + src/main/resources/META-INF/mods.toml | 2 +- 8 files changed, 255 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 2c72218..e53508b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Other mods can use the same source-safe machinery for their own registered effec ```java MobEffect frost = MyEffects.FROST.get(); -// Raw duration, vanilla merging, real Forge source, and persistent latest-source tracking. +// Raw duration, authoritative merging, real Forge source, and persistent latest-source tracking. AilmentApi.applyEffect(target, source, frost, 100, 0); // Add one stack per call, cap at 4 stacks, and refresh to 100 ticks. @@ -50,11 +50,16 @@ LivingEntity loadedSource = AilmentApi.getEffectSource(target, frost); UUID sourceId = AilmentApi.getEffectSourceUuid(target, frost); ``` -`applyScaledEffect` provides the non-stacking Arcane-scaled variant. Generic applications are server-only, reject -invalid or cross-dimension sources, and honor team/friendly-fire rules for harmful player-on-player effects. Source +`applyScaledEffect` provides the non-stacking Arcane-scaled variant. Source-aware applications reject infinite or +higher-amplifier active effects instead of creating vanilla hidden fallbacks, ensuring that the visible effect and its +single tracked owner cannot diverge. Generic applications are server-only, reject invalid or cross-dimension sources, +and honor team/friendly-fire rules for harmful player-on-player effects. Source metadata is keyed by the external effect's registry ID and is cleared lazily when queried after the effect expires; `clearEffectSource` can clear it explicitly without removing the effect. +Ordinary API applications are not rate-limited. Automatic combat integrations that need the configured PvP proc +cooldown should use `applyProcEffect` with either an `AilmentApplication` descriptor or explicit type/duration values. + Fracture stacks up to three times. Each stack removes 2 armor and 1 armor toughness; reapplication adds a stack and refreshes its duration. Arcane scales that refreshed duration but never changes the fixed attribute penalties. @@ -83,13 +88,15 @@ The single `LivingHurtEvent` pipeline applies modifiers in this order: 4. Overcharm's pre-hit Charm snapshot and direct-player-melee bonus Each applicable multiplier is applied once. The Overcharm snapshot happens before Charm is refreshed, so its first -hit has no pre-existing-Charm bonus. +hit has no pre-existing-Charm bonus. For simultaneous control effects, active Fear takes precedence over Taunt, and +Taunt takes precedence over Charm's prohibition against attacking its owner. ## Optional Ascend integration `compat.AscendCompat` checks for mod ID `ascend` and reflection-isolates its player-stats capability. Missing classes, -missing capabilities, non-player sources, and absent/unloaded source data all fall back to 1.0x. DOT potency is -snapshotted on application so source unloading does not alter an active effect. +missing capabilities, non-player sources, and absent/unloaded source data all fall back to 1.0x. An incompatible API +is logged and cached instead of retrying reflection during every application. DOT potency is snapshotted on +application so source unloading does not alter an active effect. ## Datapack extension diff --git a/src/main/java/net/fretux/ailments/api/AilmentApi.java b/src/main/java/net/fretux/ailments/api/AilmentApi.java index 0616db9..20115af 100644 --- a/src/main/java/net/fretux/ailments/api/AilmentApi.java +++ b/src/main/java/net/fretux/ailments/api/AilmentApi.java @@ -1,9 +1,9 @@ package net.fretux.ailments.api; import net.fretux.ailments.config.AilmentsConfig; -import net.fretux.ailments.compat.AscendCompat; import net.fretux.ailments.registry.ModEffects; import net.fretux.ailments.util.EffectSourceUtil; +import net.fretux.ailments.util.ControlEffectHelper; import net.fretux.ailments.util.MentalControlUtil; import net.fretux.ailments.util.ModEntityTypeTags; import net.fretux.ailments.util.HemorrhageTracker; @@ -88,9 +88,33 @@ public final class AilmentApi { return applied; } + /** + * Applies an ailment as an automatic combat proc. Unlike ordinary API applications, successful player-versus- + * player procs observe the configured per-attacker, per-ailment cooldown. + */ + public static boolean applyProcEffect(LivingEntity target, @Nullable LivingEntity source, + AilmentApplication application) { + Objects.requireNonNull(application, "application"); + if (!pvpProcReady(target, source, application.type())) return false; + boolean applied = applyEffect(target, source, application); + recordPvpProc(applied, target, source, application.type()); + return applied; + } + + /** Applies an explicit automatic combat proc with the configured PvP cooldown. */ + public static boolean applyProcEffect(LivingEntity target, @Nullable LivingEntity source, AilmentType type, + int requestedTicks, int amplifier) { + Objects.requireNonNull(type, "type"); + if (!pvpProcReady(target, source, type)) return false; + boolean applied = applyEffect(target, source, type, requestedTicks, amplifier); + recordPvpProc(applied, target, source, type); + return applied; + } + /** * Applies an effect owned by another mod with standard Forge source propagation and persistent source tracking. - * The requested duration is used exactly and is not Arcane-scaled. Vanilla effect-merging rules apply. + * The requested duration is used exactly and is not Arcane-scaled. Merging is authoritative: a stronger or + * infinite active effect is retained instead of creating an untrackable vanilla hidden fallback. */ public static boolean applyEffect(LivingEntity target, @Nullable LivingEntity source, MobEffect effect, int requestedTicks, int amplifier) { @@ -202,8 +226,7 @@ public final class AilmentApi { /** Applies one Soul Rot stack; disabling extension always refreshes the configured base duration. */ public static boolean applySoulRot(LivingEntity target, @Nullable LivingEntity source, boolean extendWithStacks) { - if (!serverValid(target) || !canSoulRot(target) - || !pvpProcReady(target, source, AilmentType.SOUL_ROT)) return false; + if (!harmfulApplicationValid(target, source) || !canSoulRot(target)) return false; MobEffect effect = ModEffects.SOUL_ROT.get(); MobEffectInstance current = target.getEffect(effect); int currentStacks = current == null ? 0 : current.getAmplifier() + 1; @@ -219,23 +242,23 @@ public final class AilmentApi { EffectSourceUtil.setPotency(target, EffectSourceUtil.SOUL_ROT, AilmentScaling.getPotencyMultiplier(source, AilmentType.SOUL_ROT)); } - return finishApplication(applied, target, source, AilmentType.SOUL_ROT); + return applied; } /** Applies an explicit, capped Soul Rot amplifier and source-scaled requested duration. */ public static boolean applySoulRot(LivingEntity target, @Nullable LivingEntity source, int requestedTicks, int amplifier) { - if (!serverValid(target) || !canSoulRot(target) - || !pvpProcReady(target, source, AilmentType.SOUL_ROT)) return false; + if (!harmfulApplicationValid(target, source) || !canSoulRot(target) || requestedTicks < 1 || amplifier < 0) + return false; int capped = Math.min(Math.max(0, amplifier), AilmentsConfig.value(AilmentsConfig.SOUL_ROT_MAX_STACKS) - 1); - return finishApplication(applyDot(target, source, ModEffects.SOUL_ROT.get(), EffectSourceUtil.SOUL_ROT, - requestedTicks, capped, AilmentType.SOUL_ROT), target, source, AilmentType.SOUL_ROT); + return applyDot(target, source, ModEffects.SOUL_ROT.get(), EffectSourceUtil.SOUL_ROT, + requestedTicks, capped, AilmentType.SOUL_ROT); } /** Applies or adds one Bleed stack, returning false for immune targets. */ public static boolean applyBleed(LivingEntity target, @Nullable LivingEntity source) { - if (!serverValid(target) || !canBleed(target) || !pvpProcReady(target, source, AilmentType.BLEED)) return false; + if (!harmfulApplicationValid(target, source) || !canBleed(target)) return false; MobEffect effect = ModEffects.BLEED.get(); MobEffectInstance current = target.getEffect(effect); int amplifier = current == null ? 0 : Math.min(current.getAmplifier() + 1, @@ -250,17 +273,18 @@ public final class AilmentApi { EffectSourceUtil.setPotency(target, EffectSourceUtil.BLEED, AilmentScaling.getPotencyMultiplier(source, AilmentType.BLEED)); } - return finishApplication(applied, target, source, AilmentType.BLEED); + return applied; } /** Applies an explicit, capped Bleed amplifier and source-scaled requested duration. */ public static boolean applyBleed(LivingEntity target, @Nullable LivingEntity source, int requestedTicks, int amplifier) { - if (!serverValid(target) || !canBleed(target) || !pvpProcReady(target, source, AilmentType.BLEED)) return false; + if (!harmfulApplicationValid(target, source) || !canBleed(target) || requestedTicks < 1 || amplifier < 0) + return false; int capped = Math.min(Math.max(0, amplifier), AilmentsConfig.value(AilmentsConfig.BLEED_MAX_AMPLIFIER)); - return finishApplication(applyDot(target, source, ModEffects.BLEED.get(), EffectSourceUtil.BLEED, - requestedTicks, capped, AilmentType.BLEED), target, source, AilmentType.BLEED); + return applyDot(target, source, ModEffects.BLEED.get(), EffectSourceUtil.BLEED, + requestedTicks, capped, AilmentType.BLEED); } /** Applies one Fracture stack and refreshes its configured, source-scaled duration. */ @@ -279,8 +303,8 @@ public final class AilmentApi { */ public static boolean applyFracture(LivingEntity target, @Nullable LivingEntity source, int requestedTicks, int amplifier) { - if (!serverValid(target) || !canFracture(target) || requestedTicks < 1 || amplifier < 0 - || !pvpProcReady(target, source, AilmentType.FRACTURE)) return false; + if (!harmfulApplicationValid(target, source) || !canFracture(target) || requestedTicks < 1 || amplifier < 0) + return false; MobEffect effect = ModEffects.FRACTURE.get(); MobEffectInstance current = target.getEffect(effect); int stackedAmplifier = current == null ? 0 : Math.min(2, current.getAmplifier() + 1); @@ -288,57 +312,55 @@ public final class AilmentApi { int duration = AilmentScaling.scaleDuration(requestedTicks, source); boolean applied = addStackingEffect(target, source, effect, duration, appliedAmplifier, true); if (applied) EffectSourceUtil.setSource(target, EffectSourceUtil.FRACTURE, source); - return finishApplication(applied, target, source, AilmentType.FRACTURE); + return applied; } public static boolean applyFear(LivingEntity target, @Nullable LivingEntity source, int requestedTicks) { - if (!hostileControlValid(target, source) || MentalControlUtil.isMentalControlImmune(target) - || target.getMaxHealth() > AilmentsConfig.value(AilmentsConfig.FEAR_IMMUNITY_HEALTH) - || !pvpProcReady(target, source, AilmentType.FEAR)) return false; + if (!hostileControlValid(target, source) || requestedTicks < 1 + || MentalControlUtil.isMentalControlImmune(target) + || target.getMaxHealth() > AilmentsConfig.value(AilmentsConfig.FEAR_IMMUNITY_HEALTH)) return false; int duration = AilmentScaling.scaleDuration(requestedTicks, source); if (target.getMaxHealth() > AilmentsConfig.value(AilmentsConfig.FEAR_REDUCED_HEALTH)) duration /= 2; if (MentalControlUtil.isMentalControlResistant(target)) duration = MentalControlUtil.resistantDuration(duration, 3); if (target instanceof EnderMan) duration = Math.max(1, duration / 2); - return finishApplication(applySourced(target, source, ModEffects.FEAR.get(), EffectSourceUtil.FEAR, - Math.max(1, duration), 0), target, source, AilmentType.FEAR); + return applySourced(target, source, ModEffects.FEAR.get(), EffectSourceUtil.FEAR, + Math.max(1, duration), 0); } public static boolean applyCharm(LivingEntity target, @Nullable LivingEntity source, int requestedTicks) { - if (!hostileControlValid(target, source) || MentalControlUtil.isMentalControlImmune(target) - || !pvpProcReady(target, source, AilmentType.CHARM)) return false; + if (!hostileControlValid(target, source) || requestedTicks < 1 + || MentalControlUtil.isMentalControlImmune(target)) return false; int duration = AilmentScaling.scaleDuration(requestedTicks, source); if (MentalControlUtil.isMentalControlResistant(target)) duration = MentalControlUtil.resistantDuration(duration, 2); - return finishApplication(applySourced(target, source, ModEffects.CHARM.get(), EffectSourceUtil.CHARM, - duration, 0), target, source, AilmentType.CHARM); + return applySourced(target, source, ModEffects.CHARM.get(), EffectSourceUtil.CHARM, duration, 0); } public static boolean applyTaunt(LivingEntity target, @Nullable LivingEntity source, int requestedTicks, int amplifier) { - if (!hostileControlValid(target, source) || MentalControlUtil.isMentalControlImmune(target) - || !pvpProcReady(target, source, AilmentType.TAUNT)) return false; + if (!hostileControlValid(target, source) || requestedTicks < 1 || amplifier < 0 + || MentalControlUtil.isMentalControlImmune(target)) return false; int duration = AilmentScaling.scaleDuration(requestedTicks, source); if (MentalControlUtil.isMentalControlResistant(target)) duration = MentalControlUtil.resistantDuration(duration, 2); - return finishApplication(applySourced(target, source, ModEffects.TAUNT.get(), EffectSourceUtil.TAUNT, - duration, Math.max(0, amplifier)), target, source, AilmentType.TAUNT); + return applySourced(target, source, ModEffects.TAUNT.get(), EffectSourceUtil.TAUNT, + duration, amplifier); } public static boolean applyOvercharm(LivingEntity target, @Nullable LivingEntity source, int requestedTicks) { - if (!serverValid(target) || !pvpProcReady(target, source, AilmentType.OVERCHARM)) return false; + if (!applicationValid(target, source) || requestedTicks < 1) return false; int duration = AilmentScaling.scaleDuration(requestedTicks, source); boolean result = addSourcedEffect(target, source, ModEffects.OVERCHARM.get(), duration, 0, true); if (result) { EffectSourceUtil.setSource(target, EffectSourceUtil.OVERCHARM, source); } - return finishApplication(result, target, source, AilmentType.OVERCHARM); + return result; } public static boolean applyWinded(LivingEntity target, @Nullable LivingEntity source, int requestedTicks) { - if (!serverValid(target) || !pvpProcReady(target, source, AilmentType.WINDED)) return false; + if (!harmfulApplicationValid(target, source) || requestedTicks < 1) return false; int duration = AilmentScaling.scaleDuration(requestedTicks, source); - return finishApplication(addSourcedEffect(target, source, ModEffects.WINDED.get(), duration, 0, true), - target, source, AilmentType.WINDED); + return addSourcedEffect(target, source, ModEffects.WINDED.get(), duration, 0, true); } public static boolean canBleed(LivingEntity target) { @@ -370,7 +392,9 @@ public final class AilmentApi { public static double getHemorrhageFraction(LivingEntity target) { return HemorrhageTracker.getFraction(target); } - public static void clearHemorrhage(LivingEntity target) { HemorrhageTracker.clear(target); } + public static void clearHemorrhage(LivingEntity target) { + if (target != null && !target.level().isClientSide) HemorrhageTracker.clear(target); + } @Nullable public static LivingEntity getCharmSource(LivingEntity target) { return EffectSourceUtil.getSource(target, EffectSourceUtil.CHARM); } @@ -392,6 +416,7 @@ public final class AilmentApi { /** Removes all registered ailments and all associated source/potency metadata. */ public static void clearAll(LivingEntity target) { + if (target == null || target.level().isClientSide) return; target.removeEffect(ModEffects.SOUL_ROT.get()); target.removeEffect(ModEffects.BLEED.get()); target.removeEffect(ModEffects.FEAR.get()); target.removeEffect(ModEffects.CHARM.get()); target.removeEffect(ModEffects.TAUNT.get()); target.removeEffect(ModEffects.OVERCHARM.get()); @@ -404,6 +429,7 @@ public final class AilmentApi { EffectSourceUtil.clear(target, EffectSourceUtil.TAUNT); EffectSourceUtil.clear(target, EffectSourceUtil.OVERCHARM); EffectSourceUtil.clear(target, EffectSourceUtil.FRACTURE); + ControlEffectHelper.clearRuntimeState(target); HemorrhageTracker.clear(target); } @@ -446,13 +472,17 @@ public final class AilmentApi { private static boolean addSourcedEffect(LivingEntity target, @Nullable LivingEntity source, MobEffect effect, int duration, int amplifier, boolean ambient, boolean visible, boolean icon) { + MobEffectInstance previous = target.getEffect(effect); + if (previous != null && (previous.isInfiniteDuration() || previous.getAmplifier() > amplifier)) return false; + if (previous == null || previous.getAmplifier() == amplifier) + return target.addEffect(instance(effect, duration, amplifier, ambient, visible, icon), source); + + // Source metadata has one authoritative owner, so never allow vanilla to retain the previous source as a + // hidden fallback. A snapshot provides a real rollback even after the live duration has been set to zero. + MobEffectInstance snapshot = new MobEffectInstance(previous); + previous.mapDuration(ignored -> 0); boolean applied = target.addEffect(instance(effect, duration, amplifier, ambient, visible, icon), source); - if (applied && AscendCompat.isAscendLoaded()) { - // Ascend observes the standard Forge source and scales duration during addEffect. Ailments already - // applied its source curve, so restore the requested duration to prevent double scaling. - MobEffectInstance active = target.getEffect(effect); - if (active != null && active.getAmplifier() == amplifier) active.mapDuration(ignored -> duration); - } + if (!applied && target.getEffect(effect) == previous) previous.update(snapshot); return applied; } private static boolean addStackingEffect(LivingEntity target, @Nullable LivingEntity source, MobEffect effect, @@ -462,18 +492,7 @@ public final class AilmentApi { private static boolean addStackingEffect(LivingEntity target, @Nullable LivingEntity source, MobEffect effect, int duration, int amplifier, boolean ambient, boolean visible, boolean icon) { - MobEffectInstance previous = target.getEffect(effect); - if (previous == null || previous.getAmplifier() >= amplifier) - return addSourcedEffect(target, source, effect, duration, amplifier, ambient, visible, icon); - - // Vanilla preserves a lower-amplifier effect as a hidden fallback when upgrading. Stacking applications use - // one authoritative stack state, so suppress that fallback without emitting a remove/add lifecycle. - int previousDuration = previous.getDuration(); - previous.mapDuration(ignored -> 0); - boolean applied = addSourcedEffect(target, source, effect, duration, amplifier, ambient, visible, icon); - if (!applied && target.getEffect(effect) == previous) - previous.mapDuration(ignored -> previousDuration); - return applied; + return addSourcedEffect(target, source, effect, duration, amplifier, ambient, visible, icon); } private static boolean externalApplicationValid(LivingEntity target, @Nullable LivingEntity source, MobEffect effect, int requestedTicks, int amplifier) { @@ -499,9 +518,18 @@ public final class AilmentApi { return target != null && !target.level().isClientSide && target.isAlive() && (!(target instanceof Player player) || !player.isSpectator()); } + private static boolean applicationValid(LivingEntity target, @Nullable LivingEntity source) { + if (!serverValid(target)) return false; + return source == null || source.isAlive() && !source.isSpectator() + && !source.level().isClientSide && source.level() == target.level(); + } + private static boolean harmfulApplicationValid(LivingEntity target, @Nullable LivingEntity source) { + if (!applicationValid(target, source)) return false; + return !(source instanceof Player sourcePlayer && target instanceof Player targetPlayer) + || source == target || sourcePlayer.canHarmPlayer(targetPlayer); + } private static boolean hostileControlValid(LivingEntity target, @Nullable LivingEntity source) { - if (!serverValid(target) || source == null || !source.isAlive() - || source.level().isClientSide || source.isSpectator()) return false; + if (source == null || !applicationValid(target, source)) return false; return !(source instanceof Player sourcePlayer && target instanceof Player targetPlayer) || sourcePlayer.canHarmPlayer(targetPlayer); } @@ -513,22 +541,21 @@ public final class AilmentApi { return (int) Math.min(Integer.MAX_VALUE, (long) a + b); } private static boolean pvpProcReady(LivingEntity target, @Nullable LivingEntity source, AilmentType type) { - if (source != null && source.level() != target.level()) return false; + if (type == AilmentType.OVERCHARM ? !applicationValid(target, source) + : !harmfulApplicationValid(target, source)) return false; if (!(source instanceof Player sourcePlayer) || !(target instanceof Player targetPlayer) || source == target) return true; - if (!sourcePlayer.canHarmPlayer(targetPlayer)) return false; String key = pvpCooldownKey(type); long now = source.level().getGameTime(); return !source.getPersistentData().contains(key) || now - source.getPersistentData().getLong(key) >= AilmentsConfig.value(AilmentsConfig.PVP_PROC_COOLDOWN_TICKS); } - private static boolean finishApplication(boolean applied, LivingEntity target, - @Nullable LivingEntity source, AilmentType type) { + private static void recordPvpProc(boolean applied, LivingEntity target, + @Nullable LivingEntity source, AilmentType type) { if (applied && source instanceof Player && target instanceof Player && source != target) { source.getPersistentData().putLong(pvpCooldownKey(type), source.level().getGameTime()); } - return applied; } private static String pvpCooldownKey(AilmentType type) { return "ascend_ailments.pvp_proc." + type.name().toLowerCase(java.util.Locale.ROOT); diff --git a/src/main/java/net/fretux/ailments/compat/AscendCompat.java b/src/main/java/net/fretux/ailments/compat/AscendCompat.java index 42cc0c9..ff199f9 100644 --- a/src/main/java/net/fretux/ailments/compat/AscendCompat.java +++ b/src/main/java/net/fretux/ailments/compat/AscendCompat.java @@ -1,5 +1,6 @@ package net.fretux.ailments.compat; +import com.mojang.logging.LogUtils; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.player.Player; import net.minecraftforge.common.capabilities.Capability; @@ -9,11 +10,15 @@ import net.minecraftforge.fml.ModList; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Optional; +import org.slf4j.Logger; /** Reflection boundary: no Ascend type appears in a loadable signature or constant-pool class reference. */ public final class AscendCompat { + private static final Logger LOGGER = LogUtils.getLogger(); private static final String PROVIDER = "net.fretux.ascend.player.PlayerStatsProvider"; - private static boolean resolved; + private static volatile boolean resolutionAttempted; + private static boolean available; + private static boolean invocationFailureLogged; private static Capability statsCapability; private static Method getAttributeLevel; @@ -22,44 +27,52 @@ public final class AscendCompat { /** Resolves the optional public API without requiring an entity; useful for lifecycle diagnostics. */ public static boolean validateIntegration() { if (!isAscendLoaded()) return false; - try { - resolve(); - return statsCapability != null && getAttributeLevel != null; - } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { - return false; - } + return resolve(); } public static int getArcane(LivingEntity source) { if (!(source instanceof Player player) || !isAscendLoaded()) return 0; try { - resolve(); - if (statsCapability == null || getAttributeLevel == null) return 0; + if (!resolve()) return 0; LazyOptional lazy = player.getCapability(statsCapability); Optional stats = lazy.resolve(); if (stats.isEmpty()) return 0; return Math.max(0, ((Number) getAttributeLevel.invoke(stats.get(), "arcane")).intValue()); - } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { + } catch (ReflectiveOperationException | LinkageError | RuntimeException error) { + disableAfterInvocationFailure(error); return 0; } } - private static synchronized void resolve() throws ReflectiveOperationException { - if (resolved) return; - Class provider = Class.forName(PROVIDER, false, AscendCompat.class.getClassLoader()); - Field capabilityField = provider.getField("PLAYER_STATS"); - Capability resolvedCapability = (Capability) capabilityField.get(null); - Class statsClass = Class.forName("net.fretux.ascend.player.PlayerStats", false, - AscendCompat.class.getClassLoader()); - Method resolvedGetter = statsClass.getMethod("getAttributeLevel", String.class); - Class returnType = resolvedGetter.getReturnType(); - boolean numericPrimitive = returnType == byte.class || returnType == short.class || returnType == int.class - || returnType == long.class || returnType == float.class || returnType == double.class; - if (!Number.class.isAssignableFrom(returnType) && !numericPrimitive) - throw new NoSuchMethodException("getAttributeLevel(String) does not return a number"); - statsCapability = resolvedCapability; - getAttributeLevel = resolvedGetter; - resolved = true; + private static synchronized boolean resolve() { + if (resolutionAttempted) return available; + resolutionAttempted = true; + try { + Class provider = Class.forName(PROVIDER, false, AscendCompat.class.getClassLoader()); + Field capabilityField = provider.getField("PLAYER_STATS"); + Capability resolvedCapability = (Capability) capabilityField.get(null); + Class statsClass = Class.forName("net.fretux.ascend.player.PlayerStats", false, + AscendCompat.class.getClassLoader()); + Method resolvedGetter = statsClass.getMethod("getAttributeLevel", String.class); + Class returnType = resolvedGetter.getReturnType(); + boolean numericPrimitive = returnType == byte.class || returnType == short.class || returnType == int.class + || returnType == long.class || returnType == float.class || returnType == double.class; + if (!Number.class.isAssignableFrom(returnType) && !numericPrimitive) + throw new NoSuchMethodException("getAttributeLevel(String) does not return a number"); + statsCapability = resolvedCapability; + getAttributeLevel = resolvedGetter; + available = true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException error) { + LOGGER.warn("Ascend is loaded, but its player-stats integration is incompatible; Arcane scaling is disabled", error); + } + return available; + } + + private static synchronized void disableAfterInvocationFailure(Throwable error) { + available = false; + if (invocationFailureLogged) return; + invocationFailureLogged = true; + LOGGER.warn("Ascend Arcane lookup failed; affected applications will use neutral scaling", error); } private AscendCompat() {} } diff --git a/src/main/java/net/fretux/ailments/event/AilmentEvents.java b/src/main/java/net/fretux/ailments/event/AilmentEvents.java index 83a953a..74b1840 100644 --- a/src/main/java/net/fretux/ailments/event/AilmentEvents.java +++ b/src/main/java/net/fretux/ailments/event/AilmentEvents.java @@ -2,6 +2,8 @@ package net.fretux.ailments.event; import net.fretux.ailments.AscendAilments; import net.fretux.ailments.api.AilmentApi; +import net.fretux.ailments.api.AilmentApplication; +import net.fretux.ailments.api.AilmentType; import net.fretux.ailments.config.AilmentsConfig; import net.fretux.ailments.damage.ModDamageSources; import net.fretux.ailments.registry.ModEffects; @@ -69,13 +71,24 @@ public final class AilmentEvents { Entity attacker = event.getSource().getEntity(); if (attacker instanceof LivingEntity living && !(living instanceof Player) && living.hasEffect(ModEffects.CHARM.get()) - && ControlEffectHelper.isOwnCharmSource(living, event.getEntity())) event.setCanceled(true); + && ControlEffectHelper.isOwnCharmSource(living, event.getEntity())) { + LivingEntity taunter = living.hasEffect(ModEffects.TAUNT.get()) + && !MentalControlUtil.isMentalControlResistant(living) + ? ControlEffectHelper.validTauntSource(living) : null; + if (taunter == null || !taunter.getUUID().equals(event.getEntity().getUUID())) event.setCanceled(true); + } } @SubscribeEvent public static void onChangeTarget(LivingChangeTargetEvent event) { if (event.getEntity().level().isClientSide) return; if (!(event.getEntity() instanceof Mob mob)) return; + LivingEntity fearSource = EffectSourceUtil.getSourceInLevel(mob, EffectSourceUtil.FEAR); + if (mob.hasEffect(ModEffects.FEAR.get()) && !MentalControlUtil.isMentalControlResistant(mob) + && fearSource != null && fearSource.isAlive()) { + event.setNewTarget(null); + return; + } LivingEntity taunter = mob.hasEffect(ModEffects.TAUNT.get()) && !MentalControlUtil.isMentalControlResistant(mob) ? ControlEffectHelper.validTauntSource(mob) : null; @@ -106,9 +119,11 @@ public final class AilmentEvents { && ControlEffectHelper.isOwnCharmSource(victim, attacker)) amount *= (float) AilmentsConfig.value(AilmentsConfig.CHARM_VICTIM_BONUS); if (attacker != null && attacker.hasEffect(ModEffects.CHARM.get())) { - amount *= (float) (ControlEffectHelper.isOwnCharmSource(attacker, victim) - ? AilmentsConfig.value(AilmentsConfig.CHARM_SOURCE_PENALTY) - : AilmentsConfig.value(AilmentsConfig.CHARM_OTHER_BONUS)); + LivingEntity charmSource = AilmentApi.getCharmSource(attacker); + if (charmSource != null) + amount *= (float) (charmSource.getUUID().equals(victim.getUUID()) + ? AilmentsConfig.value(AilmentsConfig.CHARM_SOURCE_PENALTY) + : AilmentsConfig.value(AilmentsConfig.CHARM_OTHER_BONUS)); } // 3. Taunt rules; each logically distinct rule is applied once. @@ -142,8 +157,8 @@ public final class AilmentEvents { if (!(causing instanceof LivingEntity attacker) || attacker == victim) return; if (attacker.hasEffect(ModEffects.OVERCHARM.get())) - AilmentApi.applyCharm(victim, attacker, - AilmentsConfig.value(AilmentsConfig.OVERCHARM_CHARM_DURATION)); + AilmentApi.applyProcEffect(victim, attacker, AilmentType.CHARM, + AilmentsConfig.value(AilmentsConfig.OVERCHARM_CHARM_DURATION), 0); if (damageSource.getDirectEntity() == attacker) applyTaggedWeaponAilments(attacker.getMainHandItem(), victim, attacker); } @@ -152,16 +167,23 @@ public final class AilmentEvents { if (stack.isEmpty()) return; if (isAutomaticBleedWeapon(stack) && attacker.getRandom().nextDouble() < AilmentsConfig.value(AilmentsConfig.AUTOMATIC_WEAPON_BLEED_CHANCE)) - AilmentApi.applyBleed(victim, attacker); - if (stack.is(ModItemTags.SOUL_ROT_ON_HIT)) AilmentApi.applySoulRot(victim, attacker); - if (stack.is(ModItemTags.FRACTURE_ON_HIT)) AilmentApi.applyFracture(victim, attacker); + AilmentApi.applyProcEffect(victim, attacker, AilmentApplication.stack(AilmentType.BLEED)); + if (stack.is(ModItemTags.SOUL_ROT_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentApplication.stack(AilmentType.SOUL_ROT)); + if (stack.is(ModItemTags.FRACTURE_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentApplication.stack(AilmentType.FRACTURE)); int duration = AilmentsConfig.value(AilmentsConfig.TAGGED_WEAPON_EFFECT_DURATION); - if (stack.is(ModItemTags.FEAR_ON_HIT)) AilmentApi.applyFear(victim, attacker, duration); - if (stack.is(ModItemTags.CHARM_ON_HIT)) AilmentApi.applyCharm(victim, attacker, duration); - if (stack.is(ModItemTags.TAUNT_ON_HIT)) AilmentApi.applyTaunt(victim, attacker, duration, 0); - if (stack.is(ModItemTags.OVERCHARM_ON_HIT)) AilmentApi.applyOvercharm(victim, attacker, duration); - if (stack.is(ModItemTags.WINDED_ON_HIT)) AilmentApi.applyWinded(victim, attacker, duration); + if (stack.is(ModItemTags.FEAR_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentType.FEAR, duration, 0); + if (stack.is(ModItemTags.CHARM_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentType.CHARM, duration, 0); + if (stack.is(ModItemTags.TAUNT_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentType.TAUNT, duration, 0); + if (stack.is(ModItemTags.OVERCHARM_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentType.OVERCHARM, duration, 0); + if (stack.is(ModItemTags.WINDED_ON_HIT)) + AilmentApi.applyProcEffect(victim, attacker, AilmentType.WINDED, duration, 0); } private static boolean isAutomaticBleedWeapon(ItemStack stack) { diff --git a/src/main/java/net/fretux/ailments/util/ControlEffectHelper.java b/src/main/java/net/fretux/ailments/util/ControlEffectHelper.java index 1b40099..dceaaf5 100644 --- a/src/main/java/net/fretux/ailments/util/ControlEffectHelper.java +++ b/src/main/java/net/fretux/ailments/util/ControlEffectHelper.java @@ -26,16 +26,41 @@ public final class ControlEffectHelper { public static void handleLivingTick(LivingEntity entity) { if (entity.level().isClientSide) return; - cleanDotData(entity); - handleSoulRotFood(entity); + CompoundTag data = entity.getPersistentData(); + boolean soulRotRelevant = entity.hasEffect(ModEffects.SOUL_ROT.get()) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.SOUL_ROT) + || data.contains(SOUL_FOOD) || data.contains(SOUL_SATURATION); + boolean bleedRelevant = entity.hasEffect(ModEffects.BLEED.get()) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.BLEED) || HemorrhageTracker.hasProgress(entity); + boolean fractureRelevant = entity.hasEffect(ModEffects.FRACTURE.get()) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.FRACTURE); + boolean controlRelevant = entity.hasEffect(ModEffects.FEAR.get()) || entity.hasEffect(ModEffects.TAUNT.get()) + || entity.hasEffect(ModEffects.CHARM.get()) || entity.hasEffect(ModEffects.OVERCHARM.get()) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.FEAR) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.TAUNT) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.CHARM) + || EffectSourceUtil.hasData(entity, EffectSourceUtil.OVERCHARM); + if (!soulRotRelevant && !bleedRelevant && !fractureRelevant && !controlRelevant) return; + + cleanDotData(entity, soulRotRelevant, bleedRelevant, fractureRelevant); + if (soulRotRelevant) handleSoulRotFood(entity); + if (MentalControlUtil.isMentalControlImmune(entity)) { + removeControlEffect(entity, ModEffects.FEAR.get(), EffectSourceUtil.FEAR); + removeControlEffect(entity, ModEffects.TAUNT.get(), EffectSourceUtil.TAUNT); + removeControlEffect(entity, ModEffects.CHARM.get(), EffectSourceUtil.CHARM); + } if (entity.hasEffect(ModEffects.FEAR.get())) tickFear(entity); else { EffectSourceUtil.clear(entity, EffectSourceUtil.FEAR); entity.getPersistentData().remove(FEAR_DISTANCE); clearFearPlayerPenalties(entity); } - if (entity.hasEffect(ModEffects.TAUNT.get())) tickTaunt(entity); - else EffectSourceUtil.clear(entity, EffectSourceUtil.TAUNT); + boolean fearDominant = entity.hasEffect(ModEffects.FEAR.get()) + && !MentalControlUtil.isMentalControlResistant(entity) + && validControlSource(entity, EffectSourceUtil.FEAR) != null; + if (entity.hasEffect(ModEffects.TAUNT.get())) { + if (!fearDominant) tickTaunt(entity); + } else EffectSourceUtil.clear(entity, EffectSourceUtil.TAUNT); if (entity.hasEffect(ModEffects.CHARM.get())) validateCharm(entity); else EffectSourceUtil.clear(entity, EffectSourceUtil.CHARM); if (!entity.hasEffect(ModEffects.OVERCHARM.get())) @@ -48,9 +73,14 @@ public final class ControlEffectHelper { entity.getPersistentData().remove(FEAR_DISTANCE); return; } - LivingEntity source = validControlSource(entity, EffectSourceUtil.FEAR); + LivingEntity loadedSource = EffectSourceUtil.getSourceInLevel(entity, EffectSourceUtil.FEAR); + if (loadedSource != null && !loadedSource.isAlive()) { + removeControlEffect(entity, ModEffects.FEAR.get(), EffectSourceUtil.FEAR); + entity.getPersistentData().remove(FEAR_DISTANCE); + return; + } + LivingEntity source = loadedSource; if (source == null) { - if (entity instanceof Mob mob) mob.getNavigation().stop(); entity.getPersistentData().remove(FEAR_DISTANCE); clearFearPlayerPenalties(entity); return; @@ -74,8 +104,9 @@ public final class ControlEffectHelper { if (horizontal.lengthSqr() < 1.0E-8) horizontal = new Vec3(1, 0, 0); Vec3 destination = mob.position().add(horizontal.normalize() .scale(AilmentsConfig.value(AilmentsConfig.FEAR_FLEE_DISTANCE))); - mob.getNavigation().moveTo(destination.x, destination.y, destination.z, - AilmentsConfig.value(AilmentsConfig.FEAR_FLEE_SPEED)); + if (mob.tickCount % 10 == 0) + mob.getNavigation().moveTo(destination.x, destination.y, destination.z, + AilmentsConfig.value(AilmentsConfig.FEAR_FLEE_SPEED)); } private static void restrictFearedPlayer(Player player, LivingEntity source) { @@ -107,11 +138,13 @@ public final class ControlEffectHelper { removeControlEffect(entity, ModEffects.TAUNT.get(), EffectSourceUtil.TAUNT); return; } - LivingEntity source = validControlSource(entity, EffectSourceUtil.TAUNT); - if (source == null) { - if (entity instanceof Mob mob && mob.getTarget() != null) mob.setTarget(null); + LivingEntity loadedSource = EffectSourceUtil.getSourceInLevel(entity, EffectSourceUtil.TAUNT); + if (loadedSource != null && !loadedSource.isAlive()) { + removeControlEffect(entity, ModEffects.TAUNT.get(), EffectSourceUtil.TAUNT); return; } + LivingEntity source = loadedSource; + if (source == null) return; if (MentalControlUtil.isMentalControlResistant(entity)) return; if (entity instanceof Mob mob && mob.getTarget() != source) mob.setTarget(source); else if (entity instanceof Player player) @@ -121,17 +154,31 @@ public final class ControlEffectHelper { private static void validateCharm(LivingEntity entity) { if (EffectSourceUtil.getSourceUuid(entity, EffectSourceUtil.CHARM) == null) removeControlEffect(entity, ModEffects.CHARM.get(), EffectSourceUtil.CHARM); + else { + LivingEntity source = EffectSourceUtil.getSourceInLevel(entity, EffectSourceUtil.CHARM); + if (source != null && !source.isAlive()) + removeControlEffect(entity, ModEffects.CHARM.get(), EffectSourceUtil.CHARM); + } } public static boolean isOwnCharmSource(LivingEntity charmed, LivingEntity possibleSource) { - LivingEntity source = EffectSourceUtil.getSource(charmed, EffectSourceUtil.CHARM); - return source != null && source.getUUID().equals(possibleSource.getUUID()); + UUID sourceId = EffectSourceUtil.getSourceUuid(charmed, EffectSourceUtil.CHARM); + return sourceId != null && sourceId.equals(possibleSource.getUUID()); } public static LivingEntity validTauntSource(LivingEntity entity) { return validControlSource(entity, EffectSourceUtil.TAUNT); } + /** Clears transient state that is not owned by the vanilla MobEffect instance itself. */ + public static void clearRuntimeState(LivingEntity entity) { + CompoundTag data = entity.getPersistentData(); + data.remove(FEAR_DISTANCE); + data.remove(SOUL_FOOD); + data.remove(SOUL_SATURATION); + clearFearPlayerPenalties(entity); + } + private static LivingEntity validControlSource(LivingEntity target, String key) { LivingEntity source = EffectSourceUtil.getSourceInLevel(target, key); return source != null && source.isAlive() ? source : null; @@ -180,24 +227,25 @@ public final class ControlEffectHelper { data.putFloat(SOUL_SATURATION, food.getSaturationLevel()); } - private static void cleanDotData(LivingEntity entity) { - if (!AilmentApi.canSoulRot(entity)) { + private static void cleanDotData(LivingEntity entity, boolean soulRotRelevant, boolean bleedRelevant, + boolean fractureRelevant) { + if (soulRotRelevant && !AilmentApi.canSoulRot(entity)) { if (entity.hasEffect(ModEffects.SOUL_ROT.get())) entity.removeEffect(ModEffects.SOUL_ROT.get()); EffectSourceUtil.clear(entity, EffectSourceUtil.SOUL_ROT); - } else if (!entity.hasEffect(ModEffects.SOUL_ROT.get())) { + } else if (soulRotRelevant && !entity.hasEffect(ModEffects.SOUL_ROT.get())) { EffectSourceUtil.clear(entity, EffectSourceUtil.SOUL_ROT); } - if (!AilmentApi.canBleed(entity)) { + if (bleedRelevant && !AilmentApi.canBleed(entity)) { if (entity.hasEffect(ModEffects.BLEED.get())) entity.removeEffect(ModEffects.BLEED.get()); EffectSourceUtil.clear(entity, EffectSourceUtil.BLEED); HemorrhageTracker.clearIfPresent(entity); - } else if (!entity.hasEffect(ModEffects.BLEED.get())) { + } else if (bleedRelevant && !entity.hasEffect(ModEffects.BLEED.get())) { EffectSourceUtil.clear(entity, EffectSourceUtil.BLEED); } - if (!AilmentApi.canFracture(entity)) { + if (fractureRelevant && !AilmentApi.canFracture(entity)) { if (entity.hasEffect(ModEffects.FRACTURE.get())) entity.removeEffect(ModEffects.FRACTURE.get()); EffectSourceUtil.clear(entity, EffectSourceUtil.FRACTURE); - } else if (!entity.hasEffect(ModEffects.FRACTURE.get())) { + } else if (fractureRelevant && !entity.hasEffect(ModEffects.FRACTURE.get())) { EffectSourceUtil.clear(entity, EffectSourceUtil.FRACTURE); } } diff --git a/src/main/java/net/fretux/ailments/util/EffectSourceUtil.java b/src/main/java/net/fretux/ailments/util/EffectSourceUtil.java index 0d2801f..10f7816 100644 --- a/src/main/java/net/fretux/ailments/util/EffectSourceUtil.java +++ b/src/main/java/net/fretux/ailments/util/EffectSourceUtil.java @@ -64,6 +64,12 @@ public final class EffectSourceUtil { return data.contains(key) ? Math.max(0, data.getFloat(key)) : 1.0F; } + /** Returns whether source or potency metadata exists for an ailment without resolving an entity. */ + public static boolean hasData(LivingEntity target, String ailment) { + CompoundTag data = target.getPersistentData(); + return data.contains(sourceKey(ailment)) || data.contains(potencyKey(ailment)); + } + public static void clear(LivingEntity target, String ailment) { target.getPersistentData().remove(sourceKey(ailment)); target.getPersistentData().remove(potencyKey(ailment)); diff --git a/src/main/java/net/fretux/ailments/util/HemorrhageTracker.java b/src/main/java/net/fretux/ailments/util/HemorrhageTracker.java index 19429c2..24123bd 100644 --- a/src/main/java/net/fretux/ailments/util/HemorrhageTracker.java +++ b/src/main/java/net/fretux/ailments/util/HemorrhageTracker.java @@ -54,6 +54,10 @@ public final class HemorrhageTracker { return data.contains(PROGRESS) ? Math.max(0.0, data.getDouble(PROGRESS)) : 0.0; } + public static boolean hasProgress(LivingEntity target) { + return target.getPersistentData().contains(PROGRESS); + } + public static double getFraction(LivingEntity target) { double threshold = AilmentsConfig.value(AilmentsConfig.HEMORRHAGE_THRESHOLD); return threshold <= 0 ? 0.0 : Math.min(1.0, getProgress(target) / threshold); diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 494d9b6..8fd1f35 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -75,7 +75,7 @@ description='''${mod_description}''' [[dependencies.${mod_id}]] modId="ascend" mandatory=false - versionRange="[0,)" + versionRange="[3.0.7,4)" ordering="AFTER" side="BOTH"