From 8a9f2c7acece5fc5ea9e7dcc28e2a826358e1439 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:37:20 +0100 Subject: [PATCH 1/2] feat: cut healing on Wounded targets Wounded is a new TFMC class status (a MythicMobs aura). While it lasts, the target heals at wounded-healing-multiplier (default 0.5): every heal that reaches EntityRegainHealthEvent (vanilla, MythicLib heal and lifesteal, MythicMobs heal), plus MMOCore regeneration through a compound modifier on HEALTH_REGENERATION and MAX_HEALTH_REGENERATION, since that regeneration sets the health directly. MythicMobs is a new pinned private dependency. Co-Authored-By: Claude Opus 5.5 --- .github/dependencies.sha256 | 1 + .github/scripts/install-local-dependencies.sh | 3 + .github/scripts/prepare-release.sh | 1 + README.md | 1 + pom.xml | 6 + .../net/tfminecraft/tfmccore/TFMCCore.java | 8 + .../net/tfminecraft/tfmccore/cache/Cache.java | 2 + .../tfmccore/health/WoundedHealing.java | 139 +++++++++ .../tfmccore/loader/ConfigLoader.java | 1 + src/main/resources/config.yml | 4 + src/main/resources/plugin.yml | 2 +- .../tfmccore/PluginLifecycleCoverageTest.java | 27 ++ .../tfmccore/health/WoundedHealingTest.java | 265 ++++++++++++++++++ 13 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java create mode 100644 src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index 0decee1..20ea367 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -1,5 +1,6 @@ 225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar 81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar +6df72b5b331d5d8881f2b0ab99cbaa236dd941cbbbaf69fbd05dcb412bffb7f6 libs/MythicMobs-5.13.1-SNAPSHOT-88530541.jar 5a01b37bd7442fb0f2c809cadac82ba70f75173956e04efac3336595cc070368 libs/ItemsAdder-4.0.18.jar 355f7117af95b3f8e3cb63440fcf8a3bb9808bc89ac6ac5c57142327480e9437 libs/ProtocolLib-5.5.0-SNAPSHOT.jar 44ee292392dd86b4aa4824c2e11668a9dee0333b83ae013987e1ba78d19f51a0 libs/ModelEngine-R4.1.1.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index e682518..ed4d4f0 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -14,6 +14,9 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1. mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/MMOCore-1.13.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOCore" \ -Dversion="1.13.1-SNAPSHOT-tfmc-81d511d08309" -Dpackaging=jar -DgeneratePom=true "$@" +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/MythicMobs-5.13.1-SNAPSHOT-88530541.jar" -DgroupId="local" -DartifactId="MythicMobs" \ + -Dversion="5.13.1-SNAPSHOT-88530541-tfmc-6df72b5b331d" -Dpackaging=jar -DgeneratePom=true "$@" mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/ItemsAdder-4.0.18.jar" -DgroupId="local" -DartifactId="ItemsAdder" \ diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index ee3b648..029c0ed 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -6,6 +6,7 @@ mkdir -p libs curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/355f7117af95/ProtocolLib-5.5.0-SNAPSHOT.jar?ref=$ref" > "libs/ProtocolLib-5.5.0-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/81d511d08309/MMOCore-1.13.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOCore-1.13.1-SNAPSHOT.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/6df72b5b331d/MythicMobs-5.13.1-SNAPSHOT-88530541.jar?ref=$ref" > "libs/MythicMobs-5.13.1-SNAPSHOT-88530541.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/5a01b37bd744/ItemsAdder-4.0.18.jar?ref=$ref" > "libs/ItemsAdder-4.0.18.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/44ee292392dd/ModelEngine-R4.1.1.jar?ref=$ref" > "libs/ModelEngine-R4.1.1.jar" bash .github/scripts/install-local-dependencies.sh "$@" diff --git a/README.md b/README.md index 5e49041..e690c9a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ These features give other TF-Minecraft plugins common building blocks while also - **Required pack on join** — opt-in (`resource-pack.require-on-join`); sends the pack as required and publishes `tfmccore:required-pack` player metadata (`loading`, `ready`, `failed`) for RPCharacters. Staff retry a failed load with `/tcore packretry `. - **Book appearance** — optionally hides the default glint on signed books by storing a glint override on the books (`hide-book-glint`). - **Book copies** — written books can be copied with a Book and Quill at a crafting table, even though MMOItems blocks vanilla recipes that use a disable-crafting MMOItem (`allow-book-copies`). +- **Wounded healing** — anyone under the Wounded status (a MythicMobs aura from TFMC class skills) heals by `wounded-healing-multiplier` (0.5): lifesteal, skill heals, food and MMOCore regeneration alike. Absorption shields are not affected. - **Accurate damage indicators** — MythicLib's floating damage numbers show the health the target really lost after armour, absorption hearts included, one tick after the hit. Regeneration, other heals and other damage in that tick don't move it, a killing blow shows the health that was left, and a hit that took nothing shows nothing (`accurate-damage-indicators`). - **Cross-plugin statistics** — records supported vehicle, character, crafting, skill, and faction events for staff queries. diff --git a/pom.xml b/pom.xml index 56f0a5a..43b0fa4 100644 --- a/pom.xml +++ b/pom.xml @@ -112,6 +112,12 @@ 1.13.1-SNAPSHOT-tfmc-81d511d08309 provided + + local + MythicMobs + 5.13.1-SNAPSHOT-88530541-tfmc-6df72b5b331d + provided + local ItemsAdder diff --git a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java index 9c598f4..c17e664 100644 --- a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java +++ b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java @@ -21,6 +21,7 @@ import net.tfminecraft.tfmccore.commands.SilentPermissionCommand; import net.tfminecraft.tfmccore.golem.GolemListener; import net.tfminecraft.tfmccore.health.MaxHealthRounder; +import net.tfminecraft.tfmccore.health.WoundedHealing; import net.tfminecraft.tfmccore.indicators.RealDamageIndicators; import net.tfminecraft.tfmccore.movement.SoulSandStride; import net.tfminecraft.tfmccore.loader.ConfigLoader; @@ -84,6 +85,7 @@ public class TFMCCore extends JavaPlugin{ private BookCopyUnblocker bookCopies; private MaxHealthRounder maxHealth; private SoulSandStride soulSand; + private WoundedHealing wounded; @Override public void onEnable() { @@ -192,6 +194,7 @@ public void onDisable() { if (bookCopies != null) bookCopies.unwrap(); if (maxHealth != null) maxHealth.close(); if (soulSand != null) soulSand.close(); + if (wounded != null) wounded.close(); if (stoneListener != null) { stoneListener.refundAll(); } @@ -354,6 +357,11 @@ public void registerListeners() { RealDamageIndicators.register(this, new File( getServer().getPluginManager().getPlugin("MythicLib").getDataFolder(), "indicators.yml")); } + // The Wounded status is a MythicMobs aura; the regeneration cut is a MythicLib stat modifier + if (getServer().getPluginManager().isPluginEnabled("MythicMobs") + && getServer().getPluginManager().isPluginEnabled("MythicLib")) { + wounded = WoundedHealing.register(this); + } } public void createConfigs() { diff --git a/src/main/java/net/tfminecraft/tfmccore/cache/Cache.java b/src/main/java/net/tfminecraft/tfmccore/cache/Cache.java index 68b8e23..49f0f0a 100644 --- a/src/main/java/net/tfminecraft/tfmccore/cache/Cache.java +++ b/src/main/java/net/tfminecraft/tfmccore/cache/Cache.java @@ -21,6 +21,8 @@ public class Cache { public static boolean allowBookCopies = true; public static volatile boolean soulSandStride = false; public static boolean accurateDamageIndicators = true; + // Read on every heal + public static volatile double woundedHealingMultiplier = 0.5; public static int armourTime = 7; diff --git a/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java b/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java new file mode 100644 index 0000000..12fdbc7 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java @@ -0,0 +1,139 @@ +package net.tfminecraft.tfmccore.health; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.function.DoubleSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.java.JavaPlugin; + +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.core.skills.auras.AuraRegistry; +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.StatInstance; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; +import io.lumine.mythic.lib.player.modifier.ModifierType; +import net.tfminecraft.tfmccore.cache.Cache; + +/** + * Cuts the healing of anyone under the Wounded status. + * + *

Wounded is a MythicMobs aura that TFMC class skills give ({@code tfmc_status_skills.yml}). + * Heals that reach {@link EntityRegainHealthEvent} use the amount it ends with: vanilla + * regeneration and food, MythicLib's {@code heal} (lifesteal and spell vampirism) and MythicMobs' + * {@code heal}, which fires its own heal event and then this one. So one listener covers them, and + * none is cut twice. + * + *

MMOCore's health regeneration sets the health directly and fires no heal event, so for Wounded + * players this also scales the two MythicLib stats it reads ({@link #REGEN_STATS}) by the same + * multiplier, checked every {@link #PERIOD} ticks. The modifier is taken off when the status ends, + * at quit and on disable. Absorption shields are not healing and stay as they are. + */ +public final class WoundedHealing implements Listener { + + /** The MythicMobs aura name of the status. */ + static final String AURA = "Wounded"; + /** The MythicLib stats MMOCore health regeneration reads: flat per second, and % of max health. */ + static final String[] REGEN_STATS = {"HEALTH_REGENERATION", "MAX_HEALTH_REGENERATION"}; + /** Key of the regeneration modifier. */ + static final String KEY = "tfmccore_wounded"; + /** 5 ticks: the regeneration cut starts and ends within a quarter of a second of the status. */ + static final long PERIOD = 5L; + + /** Scales a player's regeneration stats; factor 1 takes the scaling off. */ + interface RegenScale { + void set(Player player, double factor); + } + + private final Predicate wounded; + private final DoubleSupplier multiplier; + private final Supplier> players; + private final RegenScale regen; + private final Set scaled = new HashSet<>(); + private double applied = 1.0; + + WoundedHealing(Predicate wounded, DoubleSupplier multiplier, + Supplier> players, RegenScale regen) { + this.wounded = wounded; + this.multiplier = multiplier; + this.players = players; + this.regen = regen; + } + + /** Listens for heals and checks regeneration. Needs MythicMobs (the status) and MythicLib (the stats). */ + public static WoundedHealing register(JavaPlugin plugin) { + WoundedHealing healing = new WoundedHealing(WoundedHealing::hasAura, () -> Cache.woundedHealingMultiplier, + () -> plugin.getServer().getOnlinePlayers(), WoundedHealing::scaleRegen); + plugin.getServer().getPluginManager().registerEvents(healing, plugin); + plugin.getServer().getScheduler().runTaskTimer(plugin, healing::tick, PERIOD, PERIOD); + return healing; + } + + static boolean hasAura(Entity entity) { + AuraRegistry auras = MythicBukkit.inst().getSkillManager().getAuraManager() + .getAuraRegistry(entity.getUniqueId()); + return auras != null && auras.hasAura(AURA); + } + + static void scaleRegen(Player player, double factor) { + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (data == null) return; + for (String stat : REGEN_STATS) { + StatInstance instance = data.getStatMap().getInstance(stat); + instance.remove(KEY); + if (factor < 1.0) instance.addModifier(new StatModifier(KEY, stat, (factor - 1.0) * 100.0, ModifierType.COMPOUND)); + } + } + + private double factor() { + return Math.max(0.0, Math.min(1.0, multiplier.getAsDouble())); + } + + /** HIGH: after plugins that change heals at NORMAL, before MONITOR listeners read the result. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onRegainHealth(EntityRegainHealthEvent event) { + double factor = factor(); + if (factor >= 1.0 || !wounded.test(event.getEntity())) return; + event.setAmount(event.getAmount() * factor); + } + + /** Gives every Wounded player the regeneration cut and takes it off the others that had it. */ + void tick() { + double factor = factor(); + boolean changed = factor != applied; // a config reload changed the multiplier + applied = factor; + Set online = new HashSet<>(); + for (Player player : players.get()) { + UUID id = player.getUniqueId(); + online.add(id); + boolean cut = factor < 1.0 && wounded.test(player); + if (cut && (scaled.add(id) || changed)) regen.set(player, factor); + else if (!cut && scaled.remove(id)) regen.set(player, 1.0); + } + scaled.retainAll(online); + } + + /** Takes the cut off at quit, so it never waits in MythicLib's player data for the next login. */ + @EventHandler + public void onQuit(PlayerQuitEvent event) { + if (scaled.remove(event.getPlayer().getUniqueId())) regen.set(event.getPlayer(), 1.0); + } + + /** Takes the regeneration cut off everyone online who has it. Runs on disable. */ + public void close() { + for (Player player : players.get()) { + if (scaled.remove(player.getUniqueId())) regen.set(player, 1.0); + } + scaled.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/tfmccore/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmccore/loader/ConfigLoader.java index ed83a98..f4bc285 100644 --- a/src/main/java/net/tfminecraft/tfmccore/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/tfmccore/loader/ConfigLoader.java @@ -39,6 +39,7 @@ public boolean loadConfig(File configFile) { Cache.allowBookCopies = config.getBoolean("allow-book-copies", true); Cache.soulSandStride = config.getBoolean("soul-sand-stride", false); Cache.accurateDamageIndicators = config.getBoolean("accurate-damage-indicators", true); + Cache.woundedHealingMultiplier = config.getDouble("wounded-healing-multiplier", 0.5); Cache.armourTime = config.getInt("armour-time", 7); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bdc0dfb..1a654ac 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,6 +40,10 @@ soul-sand-stride: false # tick later; a killing blow shows the health that was left, a hit that took nothing shows # nothing. false shows MythicLib's own number, which leaves armour out. accurate-damage-indicators: true +# Healing on anyone under the Wounded status (a MythicMobs aura that TFMC class skills give) +# is multiplied by this: lifesteal, skill heals, regeneration and food alike. Absorption +# shields are not healing. 1 turns it off; it needs MythicMobs and MythicLib. +wounded-healing-multiplier: 0.5 drops-debug: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 806b838..d7b9bde 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs] -softdepend: [ItemsAdder, ProtocolLib, VehicleFramework, RPCharacters, AdvancedCrafting, MythicLib, MMOCore, SimpleFactions, LuckPerms] +softdepend: [ItemsAdder, ProtocolLib, VehicleFramework, RPCharacters, AdvancedCrafting, MythicLib, MythicMobs, MMOCore, SimpleFactions, LuckPerms] commands: tcore: diff --git a/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java index df4db66..e17bde8 100644 --- a/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java @@ -457,6 +457,33 @@ rig.plugin, new File("MythicLib", "indicators.yml")), } } + @ParameterizedTest + @ValueSource(strings = {"both", "mythicmobs-only", "mythiclib-only", "neither"}) + void woundedHealingNeedsMythicMobsAndMythicLibAndClosesOnDisable(String plugins) throws Exception { + boolean mythicMobs = plugins.equals("both") || plugins.equals("mythicmobs-only"); + boolean mythicLib = plugins.equals("both") || plugins.equals("mythiclib-only"); + try (Rig rig = new Rig(); + var wounded = mockStatic(net.tfminecraft.tfmccore.health.WoundedHealing.class); + var rounders = mockStatic(net.tfminecraft.tfmccore.health.MaxHealthRounder.class); + var indicators = mockStatic(net.tfminecraft.tfmccore.indicators.RealDamageIndicators.class)) { + var healing = mock(net.tfminecraft.tfmccore.health.WoundedHealing.class); + wounded.when(() -> net.tfminecraft.tfmccore.health.WoundedHealing.register(rig.plugin)).thenReturn(healing); + when(rig.manager.isPluginEnabled("MythicMobs")).thenReturn(mythicMobs); + when(rig.manager.isPluginEnabled("MythicLib")).thenReturn(mythicLib); + Plugin mythicLibPlugin = mock(Plugin.class); + when(mythicLibPlugin.getDataFolder()).thenReturn(new File("MythicLib")); + when(rig.manager.getPlugin("MythicLib")).thenReturn(mythicLibPlugin); + + rig.plugin.onEnable(); + rig.plugin.onDisable(); + + boolean loads = mythicMobs && mythicLib; + wounded.verify( + () -> net.tfminecraft.tfmccore.health.WoundedHealing.register(rig.plugin), times(loads ? 1 : 0)); + verify(healing, times(loads ? 1 : 0)).close(); + } + } + @ParameterizedTest @ValueSource(strings = {"off", "absent", "standard", "multipart"}) void requiredPackGateIsOptInAndOwnsManualSendsWhileLoading(String scenario) throws Exception { diff --git a/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java b/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java new file mode 100644 index 0000000..34e047b --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java @@ -0,0 +1,265 @@ +package net.tfminecraft.tfmccore.health; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; + +import org.bukkit.Server; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.core.skills.SkillExecutor; +import io.lumine.mythic.core.skills.auras.AuraExecutor; +import io.lumine.mythic.core.skills.auras.AuraRegistry; +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.StatInstance; +import io.lumine.mythic.lib.api.stat.StatMap; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; +import net.tfminecraft.tfmccore.cache.Cache; + +class WoundedHealingTest { + + private final Player player = mock(Player.class); + private final UUID id = UUID.randomUUID(); + private final List online = new ArrayList<>(List.of(player)); + private final List regenCalls = new ArrayList<>(); + private final WoundedHealing.RegenScale regen = (p, factor) -> regenCalls.add(p.getName() + "=" + factor); + + WoundedHealingTest() { + when(player.getUniqueId()).thenReturn(id); + when(player.getName()).thenReturn("P"); + } + + @AfterEach + void resetMultiplier() { + Cache.woundedHealingMultiplier = 0.5; + } + + private WoundedHealing healing(Predicate wounded, double multiplier) { + return new WoundedHealing(wounded, () -> multiplier, () -> online, regen); + } + + private EntityRegainHealthEvent heal(double amount) { + return new EntityRegainHealthEvent(player, amount, RegainReason.MAGIC); + } + + @Test + void woundedTargetsHealByTheMultiplier() { + EntityRegainHealthEvent event = heal(6.0); + + healing(entity -> entity == player, 0.5).onRegainHealth(event); + + assertEquals(3.0, event.getAmount(), 1e-9); + } + + @Test + void healthyTargetsHealInFull() { + EntityRegainHealthEvent event = heal(6.0); + + healing(entity -> false, 0.5).onRegainHealth(event); + + assertEquals(6.0, event.getAmount(), 1e-9); + } + + @Test + void aMultiplierOfOneTurnsItOffWithoutLookingUpTheAura() { + AtomicInteger lookups = new AtomicInteger(); + WoundedHealing healing = healing(entity -> lookups.incrementAndGet() > 0, 1.0); + EntityRegainHealthEvent event = heal(6.0); + + healing.onRegainHealth(event); + healing.tick(); + + assertEquals(6.0, event.getAmount(), 1e-9); + assertEquals(0, lookups.get()); + assertTrue(regenCalls.isEmpty()); + } + + @Test + void theMultiplierIsClampedBetweenZeroAndOne() { + EntityRegainHealthEvent negative = heal(6.0); + healing(entity -> true, -2.0).onRegainHealth(negative); + assertEquals(0.0, negative.getAmount(), 1e-9); + + EntityRegainHealthEvent above = heal(6.0); + healing(entity -> true, 3.0).onRegainHealth(above); + assertEquals(6.0, above.getAmount(), 1e-9); + } + + @Test + void regenerationIsCutOnceWhileWoundedAndRestoredAfter() { + AtomicReference wounded = new AtomicReference<>(true); + WoundedHealing healing = healing(entity -> wounded.get(), 0.5); + + healing.tick(); + healing.tick(); + wounded.set(false); + healing.tick(); + healing.tick(); + + assertEquals(List.of("P=0.5", "P=1.0"), regenCalls); + } + + @Test + void aChangedMultiplierIsReappliedToPlayersWhoAreStillWounded() { + double[] multiplier = {0.5}; + WoundedHealing healing = new WoundedHealing(entity -> true, () -> multiplier[0], () -> online, regen); + + healing.tick(); + multiplier[0] = 0.25; + healing.tick(); + + assertEquals(List.of("P=0.5", "P=0.25"), regenCalls); + } + + @Test + void quitAndCloseTakeTheCutOffAndForgetPlayersWhoLeft() { + WoundedHealing healing = healing(entity -> true, 0.5); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + + healing.tick(); + healing.onQuit(quit); + healing.onQuit(quit); + assertEquals(List.of("P=0.5", "P=1.0"), regenCalls); + + regenCalls.clear(); + healing.tick(); + healing.close(); + healing.close(); + assertEquals(List.of("P=0.5", "P=1.0"), regenCalls); + + regenCalls.clear(); + healing.tick(); + online.clear(); + healing.tick(); + online.add(player); + healing.tick(); + assertEquals(List.of("P=0.5", "P=0.5"), regenCalls, "a player who left without a quit event is scaled again"); + } + + @Test + void registerListensSchedulesAndReadsTheConfiguredMultiplier() { + JavaPlugin plugin = mock(JavaPlugin.class); + Server server = mock(Server.class); + PluginManager manager = mock(PluginManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + when(server.getScheduler()).thenReturn(scheduler); + doReturn(List.of()).when(server).getOnlinePlayers(); + Cache.woundedHealingMultiplier = 0.25; + + try (var mythic = mockStatic(MythicBukkit.class)) { + AuraRegistry auras = mockAuras(mythic, id); + when(auras.hasAura(WoundedHealing.AURA)).thenReturn(true); + + WoundedHealing healing = WoundedHealing.register(plugin); + EntityRegainHealthEvent event = heal(8.0); + healing.onRegainHealth(event); + + verify(manager).registerEvents(healing, plugin); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), task.capture(), eq(WoundedHealing.PERIOD), eq(WoundedHealing.PERIOD)); + task.getValue().run(); + verify(server).getOnlinePlayers(); + assertEquals(2.0, event.getAmount(), 1e-9); + } + } + + @Test + void theAuraLookupNeedsTheWoundedAura() { + try (var mythic = mockStatic(MythicBukkit.class)) { + AuraRegistry auras = mockAuras(mythic, id); + when(auras.hasAura(WoundedHealing.AURA)).thenReturn(false); + + assertFalse(WoundedHealing.hasAura(player)); + } + } + + @Test + void anEntityWithNoAurasIsNotWounded() { + try (var mythic = mockStatic(MythicBukkit.class)) { + MythicBukkit inst = mock(MythicBukkit.class); + SkillExecutor skills = mock(SkillExecutor.class); + AuraExecutor manager = mock(AuraExecutor.class); + mythic.when(MythicBukkit::inst).thenReturn(inst); + when(inst.getSkillManager()).thenReturn(skills); + when(skills.getAuraManager()).thenReturn(manager); + when(manager.getAuraRegistry(id)).thenReturn(null); + + assertFalse(WoundedHealing.hasAura(player)); + } + } + + @Test + void scaleRegenPutsACompoundModifierOnBothRegenerationStatsAndTakesItOff() { + try (var players = mockStatic(MMOPlayerData.class)) { + MMOPlayerData data = mock(MMOPlayerData.class); + StatMap stats = mock(StatMap.class); + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + when(data.getStatMap()).thenReturn(stats); + List instances = new ArrayList<>(); + for (String stat : WoundedHealing.REGEN_STATS) { + StatInstance instance = mock(StatInstance.class); + when(stats.getInstance(stat)).thenReturn(instance); + instances.add(instance); + } + + WoundedHealing.scaleRegen(player, 0.5); + for (StatInstance instance : instances) { + verify(instance).remove(WoundedHealing.KEY); + ArgumentCaptor modifier = ArgumentCaptor.forClass(StatModifier.class); + verify(instance).addModifier(modifier.capture()); + assertEquals(-50.0, modifier.getValue().getValue(), 1e-9); + assertEquals(WoundedHealing.KEY, modifier.getValue().getKey()); + assertEquals(io.lumine.mythic.lib.player.modifier.ModifierType.COMPOUND, modifier.getValue().getType()); + } + + WoundedHealing.scaleRegen(player, 1.0); + for (StatInstance instance : instances) { + verify(instance, times(2)).remove(WoundedHealing.KEY); + verify(instance, times(1)).addModifier(any(StatModifier.class)); + } + } + } + + @Test + void scaleRegenSkipsPlayersMythicLibHasNotLoaded() { + try (var players = mockStatic(MMOPlayerData.class)) { + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(null); + + assertDoesNotThrow(() -> WoundedHealing.scaleRegen(player, 0.5)); + } + } + + private static AuraRegistry mockAuras(MockedStatic mythic, UUID id) { + MythicBukkit inst = mock(MythicBukkit.class); + SkillExecutor skills = mock(SkillExecutor.class); + AuraExecutor manager = mock(AuraExecutor.class); + AuraRegistry auras = mock(AuraRegistry.class); + mythic.when(MythicBukkit::inst).thenReturn(inst); + when(inst.getSkillManager()).thenReturn(skills); + when(skills.getAuraManager()).thenReturn(manager); + when(manager.getAuraRegistry(id)).thenReturn(auras); + return auras; + } +} From 9abdc2482bba7c4d71600f055768a2c7ec89ffdf Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:07:24 +0100 Subject: [PATCH 2/2] fix: read-only Wounded aura lookup and retry the regen cut getAuraRegistry creates and keeps a registry for every entity it is asked about (computeIfAbsent), so every healing mob stayed in MythicMobs' map; use the read-only getHasAura. The regeneration cut is now retried when MythicLib has not loaded the player yet, a NaN multiplier turns the cut off, and the Javadoc notes the MMOCore assumption behind the single regeneration cut. Co-Authored-By: Claude Opus 5.5 --- .../tfmccore/health/WoundedHealing.java | 31 +++--- .../tfmccore/health/WoundedHealingTest.java | 97 +++++++++++++------ 2 files changed, 85 insertions(+), 43 deletions(-) diff --git a/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java b/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java index 12fdbc7..e904bce 100644 --- a/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java +++ b/src/main/java/net/tfminecraft/tfmccore/health/WoundedHealing.java @@ -17,8 +17,8 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.java.JavaPlugin; +import io.lumine.mythic.bukkit.BukkitAdapter; import io.lumine.mythic.bukkit.MythicBukkit; -import io.lumine.mythic.core.skills.auras.AuraRegistry; import io.lumine.mythic.lib.api.player.MMOPlayerData; import io.lumine.mythic.lib.api.stat.StatInstance; import io.lumine.mythic.lib.api.stat.modifier.StatModifier; @@ -38,6 +38,11 @@ * players this also scales the two MythicLib stats it reads ({@link #REGEN_STATS}) by the same * multiplier, checked every {@link #PERIOD} ticks. The modifier is taken off when the status ends, * at quit and on disable. Absorption shields are not healing and stay as they are. + * + *

Regeneration is cut once only because MMOCore 1.13.1 never registers MythicLib's + * {@code HealthUpdateEventSupplier}, so its bridge from regeneration to {@link EntityRegainHealthEvent} + * never runs. Re-check this after an MMOCore update: with that bridge, the stat modifier and the + * listener would both cut regeneration. */ public final class WoundedHealing implements Listener { @@ -50,9 +55,9 @@ public final class WoundedHealing implements Listener { /** 5 ticks: the regeneration cut starts and ends within a quarter of a second of the status. */ static final long PERIOD = 5L; - /** Scales a player's regeneration stats; factor 1 takes the scaling off. */ + /** Scales a player's regeneration stats; factor 1 takes the scaling off. False if it could not yet. */ interface RegenScale { - void set(Player player, double factor); + boolean set(Player player, double factor); } private final Predicate wounded; @@ -79,24 +84,26 @@ public static WoundedHealing register(JavaPlugin plugin) { return healing; } + /** Read-only: getAuraRegistry would create and keep a registry for every entity it is asked about. */ static boolean hasAura(Entity entity) { - AuraRegistry auras = MythicBukkit.inst().getSkillManager().getAuraManager() - .getAuraRegistry(entity.getUniqueId()); - return auras != null && auras.hasAura(AURA); + return MythicBukkit.inst().getSkillManager().getAuraManager().getHasAura(BukkitAdapter.adapt(entity), AURA); } - static void scaleRegen(Player player, double factor) { + static boolean scaleRegen(Player player, double factor) { MMOPlayerData data = MMOPlayerData.getOrNull(player); - if (data == null) return; + if (data == null) return false; // MythicLib has not loaded the player yet; the next tick tries again for (String stat : REGEN_STATS) { StatInstance instance = data.getStatMap().getInstance(stat); instance.remove(KEY); if (factor < 1.0) instance.addModifier(new StatModifier(KEY, stat, (factor - 1.0) * 100.0, ModifierType.COMPOUND)); } + return true; } private double factor() { - return Math.max(0.0, Math.min(1.0, multiplier.getAsDouble())); + double value = multiplier.getAsDouble(); + if (Double.isNaN(value)) return 1.0; + return Math.max(0.0, Math.min(1.0, value)); } /** HIGH: after plugins that change heals at NORMAL, before MONITOR listeners read the result. */ @@ -117,8 +124,10 @@ void tick() { UUID id = player.getUniqueId(); online.add(id); boolean cut = factor < 1.0 && wounded.test(player); - if (cut && (scaled.add(id) || changed)) regen.set(player, factor); - else if (!cut && scaled.remove(id)) regen.set(player, 1.0); + if (cut && (!scaled.contains(id) || changed)) { + if (regen.set(player, factor)) scaled.add(id); + else scaled.remove(id); // not applied: try again on the next tick + } else if (!cut && scaled.remove(id)) regen.set(player, 1.0); } scaled.retainAll(online); } diff --git a/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java b/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java index 34e047b..9753669 100644 --- a/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/health/WoundedHealingTest.java @@ -25,10 +25,11 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import io.lumine.mythic.api.adapters.AbstractEntity; +import io.lumine.mythic.bukkit.BukkitAdapter; import io.lumine.mythic.bukkit.MythicBukkit; import io.lumine.mythic.core.skills.SkillExecutor; import io.lumine.mythic.core.skills.auras.AuraExecutor; -import io.lumine.mythic.core.skills.auras.AuraRegistry; import io.lumine.mythic.lib.api.player.MMOPlayerData; import io.lumine.mythic.lib.api.stat.StatInstance; import io.lumine.mythic.lib.api.stat.StatMap; @@ -41,7 +42,8 @@ class WoundedHealingTest { private final UUID id = UUID.randomUUID(); private final List online = new ArrayList<>(List.of(player)); private final List regenCalls = new ArrayList<>(); - private final WoundedHealing.RegenScale regen = (p, factor) -> regenCalls.add(p.getName() + "=" + factor); + private boolean regenLoaded = true; + private final WoundedHealing.RegenScale regen = (p, factor) -> regenCalls.add(p.getName() + "=" + factor) && regenLoaded; WoundedHealingTest() { when(player.getUniqueId()).thenReturn(id); @@ -104,6 +106,47 @@ void theMultiplierIsClampedBetweenZeroAndOne() { assertEquals(6.0, above.getAmount(), 1e-9); } + @Test + void aNaNMultiplierTurnsItOff() { + EntityRegainHealthEvent event = heal(6.0); + WoundedHealing healing = healing(entity -> true, Double.NaN); + + healing.onRegainHealth(event); + healing.tick(); + + assertEquals(6.0, event.getAmount(), 1e-9); + assertTrue(regenCalls.isEmpty()); + } + + @Test + void aPlayerMythicLibHasNotLoadedIsRetriedOnTheNextTick() { + WoundedHealing healing = healing(entity -> true, 0.5); + regenLoaded = false; + + healing.tick(); + regenLoaded = true; + healing.tick(); + healing.tick(); + + assertEquals(List.of("P=0.5", "P=0.5"), regenCalls, "retried once, then kept"); + } + + @Test + void aChangedMultiplierThatCannotApplyYetIsRetried() { + double[] multiplier = {0.5}; + WoundedHealing healing = new WoundedHealing(entity -> true, () -> multiplier[0], () -> online, regen); + + healing.tick(); + multiplier[0] = 0.25; + regenLoaded = false; + healing.tick(); + regenLoaded = true; + healing.tick(); + healing.tick(); + + assertEquals(List.of("P=0.5", "P=0.25", "P=0.25"), regenCalls); + } + @Test void regenerationIsCutOnceWhileWoundedAndRestoredAfter() { AtomicReference wounded = new AtomicReference<>(true); @@ -168,9 +211,11 @@ void registerListensSchedulesAndReadsTheConfiguredMultiplier() { doReturn(List.of()).when(server).getOnlinePlayers(); Cache.woundedHealingMultiplier = 0.25; - try (var mythic = mockStatic(MythicBukkit.class)) { - AuraRegistry auras = mockAuras(mythic, id); - when(auras.hasAura(WoundedHealing.AURA)).thenReturn(true); + try (var mythic = mockStatic(MythicBukkit.class); var adapter = mockStatic(BukkitAdapter.class)) { + AuraExecutor auras = mockAuras(mythic); + AbstractEntity adapted = mock(AbstractEntity.class); + adapter.when(() -> BukkitAdapter.adapt((Entity) player)).thenReturn(adapted); // the Entity overload, as in hasAura + when(auras.getHasAura(adapted, WoundedHealing.AURA)).thenReturn(true); WoundedHealing healing = WoundedHealing.register(plugin); EntityRegainHealthEvent event = heal(8.0); @@ -186,27 +231,17 @@ void registerListensSchedulesAndReadsTheConfiguredMultiplier() { } @Test - void theAuraLookupNeedsTheWoundedAura() { - try (var mythic = mockStatic(MythicBukkit.class)) { - AuraRegistry auras = mockAuras(mythic, id); - when(auras.hasAura(WoundedHealing.AURA)).thenReturn(false); - - assertFalse(WoundedHealing.hasAura(player)); - } - } - - @Test - void anEntityWithNoAurasIsNotWounded() { - try (var mythic = mockStatic(MythicBukkit.class)) { - MythicBukkit inst = mock(MythicBukkit.class); - SkillExecutor skills = mock(SkillExecutor.class); - AuraExecutor manager = mock(AuraExecutor.class); - mythic.when(MythicBukkit::inst).thenReturn(inst); - when(inst.getSkillManager()).thenReturn(skills); - when(skills.getAuraManager()).thenReturn(manager); - when(manager.getAuraRegistry(id)).thenReturn(null); - + void theAuraLookupIsTheReadOnlyHasAura() { + try (var mythic = mockStatic(MythicBukkit.class); var adapter = mockStatic(BukkitAdapter.class)) { + AuraExecutor auras = mockAuras(mythic); + AbstractEntity adapted = mock(AbstractEntity.class); + adapter.when(() -> BukkitAdapter.adapt((Entity) player)).thenReturn(adapted); // the Entity overload, as in hasAura + when(auras.getHasAura(adapted, WoundedHealing.AURA)).thenReturn(true, false); + + assertTrue(WoundedHealing.hasAura(player)); assertFalse(WoundedHealing.hasAura(player)); + verify(auras, never()).getAuraRegistry(any(UUID.class)); + verify(auras, never()).getAuraRegistry(any(AbstractEntity.class)); } } @@ -224,7 +259,7 @@ void scaleRegenPutsACompoundModifierOnBothRegenerationStatsAndTakesItOff() { instances.add(instance); } - WoundedHealing.scaleRegen(player, 0.5); + assertTrue(WoundedHealing.scaleRegen(player, 0.5)); for (StatInstance instance : instances) { verify(instance).remove(WoundedHealing.KEY); ArgumentCaptor modifier = ArgumentCaptor.forClass(StatModifier.class); @@ -247,19 +282,17 @@ void scaleRegenSkipsPlayersMythicLibHasNotLoaded() { try (var players = mockStatic(MMOPlayerData.class)) { players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(null); - assertDoesNotThrow(() -> WoundedHealing.scaleRegen(player, 0.5)); + assertFalse(WoundedHealing.scaleRegen(player, 0.5)); } } - private static AuraRegistry mockAuras(MockedStatic mythic, UUID id) { + private static AuraExecutor mockAuras(MockedStatic mythic) { MythicBukkit inst = mock(MythicBukkit.class); SkillExecutor skills = mock(SkillExecutor.class); - AuraExecutor manager = mock(AuraExecutor.class); - AuraRegistry auras = mock(AuraRegistry.class); + AuraExecutor auras = mock(AuraExecutor.class); mythic.when(MythicBukkit::inst).thenReturn(inst); when(inst.getSkillManager()).thenReturn(skills); - when(skills.getAuraManager()).thenReturn(manager); - when(manager.getAuraRegistry(id)).thenReturn(auras); + when(skills.getAuraManager()).thenReturn(auras); return auras; } }