diff --git a/Changelog.txt b/Changelog.txt index a9ff7607f..10ea9e316 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -6,6 +6,9 @@ Version 2.2.052 Fixed bug where Powered Shot (Crossbows) was reading MaxDamage from the Archery config section instead of its own Fixed bug where Smelting Vanilla XP multiplier was not using Skills.Smelting.VanillaXPMultiplier values from advanced.yml Fixed Fishing skill only working with main-hand fishing rod when fishing + Fixed bug where mob custom names could be permanently corrupted after healthbar display (see notes) + Fixed bug where vanilla mobs (null custom name) had their name incorrectly restored as an empty string instead of null after healthbar display + Fixed bug where hitting a mob multiple times during the healthbar display window caused the name to restore too early (see notes) Changed mcMMO behavior around immature crop drops, no longer blocking drops from immature crops (See notes) Added 'Skills.Crossbows.PoweredShot.RankDamageMultiplier' to advanced.yml Added 'Skills.Crossbows.PoweredShot.MaxDamage' to advanced.yml @@ -19,6 +22,8 @@ Version 2.2.052 (Codebase) Updated deprecated durability API usage to ItemMeta Damageable API (see notes) (Codebase) Removed obsolete multishot arrow metadata marker system, modernized to native Spigot/Paper APIs (Codebase) Simplified crossbow arrow handling by eliminating unnecessary custom metadata tracking + (Codebase) Replaced three separate healthbar metadata keys with a single HealthbarSnapshot record, eliminating duplicate restore logic spread across MobHealthDisplayUpdaterTask, CombatUtils, and TransientMetadataTools + (Codebase) Rewrote MobHealthDisplayUpdaterTask as a self-managing repeating polling task using lastHitMs timestamps to extend the display window on re-hits, replacing the single-shot runAtEntityLater approach NOTES: In this update I've added two new advanced.yml settings for more granular control of the enchant level cap for both Repair and Salvage, this is an alternative to flipping on ExploitFix.UnsafeEnchantments in experience.yml which simply uncaps both skills. @@ -29,6 +34,7 @@ Version 2.2.052 The fishing hand-context changes in this update may fix reports where off-hand fishing seemed quicker than main-hand fishing. Durability handling now uses ItemMeta Damageable APIs instead of deprecated ItemStack durability methods. This may address some custom armor durability issues, but if you can still reproduce custom armor repairing when struck by axes, please report the bug with exact reproduction steps and custom-item/plugin details. + The mob custom name corruption bug: if a mob was hit again before the healthbar display timer expired, the healthbar string was being saved as the "original" name, so the mob would permanently show healthbar text after the timer fired. Version 2.2.051 Fixed bug which caused trickshot-bounced arrows to have much longer potion durations than intended (Thanks flyncodes) diff --git a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java index 61082eef4..03e34fa00 100644 --- a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java +++ b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java @@ -252,7 +252,13 @@ public class GeneralConfig extends BukkitConfig { } public int getMobHealthbarTime() { - return Math.max(1, config.getInt("Mob_Healthbar.Display_Time", 3)); + final int configured = config.getInt("Mob_Healthbar.Display_Time", 3); + // Negative values (previously used as an undocumented "permanent display" mode) are no + // longer supported. Clamp them to 20× the default (60 s) so the healthbar still clears. + if (configured < 0) { + return 60; + } + return Math.max(1, configured); } /* Scoreboards */ diff --git a/src/main/java/com/gmail/nossr50/datatypes/meta/HealthbarSnapshot.java b/src/main/java/com/gmail/nossr50/datatypes/meta/HealthbarSnapshot.java new file mode 100644 index 000000000..e12a42ff6 --- /dev/null +++ b/src/main/java/com/gmail/nossr50/datatypes/meta/HealthbarSnapshot.java @@ -0,0 +1,27 @@ +package com.gmail.nossr50.datatypes.meta; + +import org.jetbrains.annotations.Nullable; + +/** + * Immutable snapshot of a mob's name state captured before mcMMO replaces it with a healthbar + * display. Stored in entity metadata under + * {@link com.gmail.nossr50.util.MetadataConstants#METADATA_KEY_HEALTHBAR_SNAPSHOT} and used to + * restore the entity to its exact pre-healthbar state. + * + *
{@code previousCustomName} is {@code null} when the mob had no custom name before the + * healthbar was applied. This is preserved exactly — never coerced to an empty string — so that + * restoration via {@code setCustomName(null)} correctly clears the custom name slot rather than + * setting it to an empty string. + * + * @param previousCustomName the mob's custom name before healthbar was applied, or {@code null} + * @param previousNameVisible whether the mob's custom name was visible before healthbar was applied + * @param lastHitMs wall-clock time of the most recent hit that refreshed this display, + * in milliseconds from {@link System#currentTimeMillis()}; updated on + * every re-hit so the cleanup task can extend the display window + */ +public record HealthbarSnapshot( + @Nullable String previousCustomName, + boolean previousNameVisible, + long lastHitMs +) { +} diff --git a/src/main/java/com/gmail/nossr50/datatypes/meta/OldName.java b/src/main/java/com/gmail/nossr50/datatypes/meta/OldName.java deleted file mode 100644 index 9aac9b224..000000000 --- a/src/main/java/com/gmail/nossr50/datatypes/meta/OldName.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gmail.nossr50.datatypes.meta; - -import com.gmail.nossr50.mcMMO; -import org.bukkit.metadata.FixedMetadataValue; - -/** - * This class is for storing mob names since we switch them to heart values - */ -public class OldName extends FixedMetadataValue { - - public OldName(String oldName, mcMMO plugin) { - super(plugin, oldName); - } - -} diff --git a/src/main/java/com/gmail/nossr50/listeners/EntityListener.java b/src/main/java/com/gmail/nossr50/listeners/EntityListener.java index 8e49769af..223c60e59 100644 --- a/src/main/java/com/gmail/nossr50/listeners/EntityListener.java +++ b/src/main/java/com/gmail/nossr50/listeners/EntityListener.java @@ -453,13 +453,7 @@ public class EntityListener implements Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) public void onEntityDamageMonitor(EntityDamageByEntityEvent entityDamageEvent) { - if (entityDamageEvent.getEntity() instanceof LivingEntity livingEntity) { - - if (entityDamageEvent.getFinalDamage() >= livingEntity.getHealth()) { - //This sets entity names back to whatever they are supposed to be - CombatUtils.fixNames(livingEntity); - } - } + CombatUtils.restoreMobNameIfLethal(entityDamageEvent); if (entityDamageEvent.getDamager() instanceof Arrow arrow) { CombatUtils.delayArrowMetaCleanup(arrow); @@ -530,6 +524,23 @@ public class EntityListener implements Listener { } } + /** + * Monitor non-entity damage for lethal hits. + * + * EntityDamageByEntityEvent already has its own monitor path above; this fills the gap for + * lethal environmental damage where Slime/MagmaCube split can still inherit temporary names. + * + * @param entityDamageEvent The event to monitor + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) + public void onEntityDamageMonitor(EntityDamageEvent entityDamageEvent) { + if (entityDamageEvent instanceof EntityDamageByEntityEvent) { + return; + } + + CombatUtils.restoreMobNameIfLethal(entityDamageEvent); + } + public boolean checkIfInPartyOrSamePlayer(Cancellable event, Player defendingPlayer, Player attackingPlayer) { // This check is probably necessary outside of the party system diff --git a/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java b/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java index 7dd115246..c4a225478 100644 --- a/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java +++ b/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java @@ -10,7 +10,6 @@ import com.gmail.nossr50.datatypes.skills.subskills.taming.CallOfTheWildType; import com.gmail.nossr50.events.McMMOReplaceVanillaTreasureEvent; import com.gmail.nossr50.locale.LocaleLoader; import com.gmail.nossr50.mcMMO; -import com.gmail.nossr50.runnables.MobHealthDisplayUpdaterTask; import com.gmail.nossr50.runnables.player.PlayerProfileLoadingTask; import com.gmail.nossr50.skills.fishing.FishingManager; import com.gmail.nossr50.skills.herbalism.HerbalismManager; @@ -183,7 +182,7 @@ public class PlayerListener implements Listener { } // temporarily clear the mob's name - new MobHealthDisplayUpdaterTask(attacker).run(); + MobHealthbarUtils.restoreNameFromSnapshot(attacker); // set the name back mcMMO.p.getFoliaLib().getScheduler().runAtEntityLater(attacker, diff --git a/src/main/java/com/gmail/nossr50/runnables/MobHealthDisplayUpdaterTask.java b/src/main/java/com/gmail/nossr50/runnables/MobHealthDisplayUpdaterTask.java index 45d28c557..230fc2251 100644 --- a/src/main/java/com/gmail/nossr50/runnables/MobHealthDisplayUpdaterTask.java +++ b/src/main/java/com/gmail/nossr50/runnables/MobHealthDisplayUpdaterTask.java @@ -1,31 +1,90 @@ package com.gmail.nossr50.runnables; -import com.gmail.nossr50.mcMMO; +import com.gmail.nossr50.datatypes.meta.HealthbarSnapshot; import com.gmail.nossr50.util.CancellableRunnable; -import com.gmail.nossr50.util.MetadataConstants; +import com.gmail.nossr50.util.MobHealthbarUtils; +import java.util.function.LongSupplier; import org.bukkit.entity.LivingEntity; +import org.jetbrains.annotations.NotNull; public class MobHealthDisplayUpdaterTask extends CancellableRunnable { - private final LivingEntity target; - public MobHealthDisplayUpdaterTask(LivingEntity target) { + /** + * Polling interval after the initial display window elapses, in ticks. + * Balances responsiveness against redundant checks. + */ + public static final int POLL_INTERVAL_TICKS = 5; + + /** + * Number of consecutive polls with an unchanged {@code lastHitMs} before the task forcibly + * restores and exits. At 5 ticks/poll (250 ms each) this is 25 seconds of no-hit activity. + * + *
This is a semantic failsafe rather than a raw count: the counter resets whenever a + * re-hit updates {@code lastHitMs} in the snapshot, so a legitimately long boss fight cannot + * trigger early cancellation as long as the mob keeps being hit. Only truly idle tasks — where + * nothing is updating the snapshot but the elapsed check has not yet fired — will hit this + * limit. + */ + static final int STALE_POLL_LIMIT = 100; + + private final @NotNull LivingEntity target; + private final long displayTimeMs; + private final @NotNull LongSupplier timeSource; + private int stalePollCount = 0; + private long lastObservedLastHitMs = Long.MIN_VALUE; + + public MobHealthDisplayUpdaterTask(@NotNull LivingEntity target, long displayTimeMs) { + this(target, displayTimeMs, System::currentTimeMillis); + } + + /** Package-private — allows unit tests to inject a controllable time source. */ + MobHealthDisplayUpdaterTask(@NotNull LivingEntity target, long displayTimeMs, + @NotNull LongSupplier timeSource) { this.target = target; + this.displayTimeMs = displayTimeMs; + this.timeSource = timeSource; } @Override public void run() { - if (target.hasMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME)) { - target.setCustomName( - target.getMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME).get(0) - .asString()); - target.removeMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME, mcMMO.p); + // Entity left the world — entity-removal cleanup handles metadata via + // TransientMetadataTools. No restore needed here. + if (!target.isValid()) { + this.cancel(); + return; } - if (target.hasMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY)) { - target.setCustomNameVisible( - target.getMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY).get(0) - .asBoolean()); - target.removeMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY, mcMMO.p); + // Metadata already removed — another code path (lethal damage handler, entity cleanup) + // already restored the name. Nothing to do. + final HealthbarSnapshot snapshot = MobHealthbarUtils.getHealthbarSnapshot(target); + if (snapshot == null) { + this.cancel(); + return; + } + + // Primary exit: display time elapsed since the most recent hit. + final long elapsed = timeSource.getAsLong() - snapshot.lastHitMs(); + if (elapsed >= displayTimeMs) { + MobHealthbarUtils.restoreNameFromSnapshot(target); + this.cancel(); + return; + } + + // Stale-activity failsafe: track whether lastHitMs has changed since the previous poll. + // A change means the mob was re-hit — reset the counter. No change means idle — increment. + // After STALE_POLL_LIMIT consecutive idle polls the task has overstayed its welcome: + // restore and exit. This is strictly a fallback for configs with unusually long display + // times where the elapsed check alone would take many minutes to fire. + if (snapshot.lastHitMs() == lastObservedLastHitMs) { + stalePollCount++; + } else { + stalePollCount = 0; + lastObservedLastHitMs = snapshot.lastHitMs(); + } + + if (stalePollCount >= STALE_POLL_LIMIT) { + MobHealthbarUtils.restoreNameFromSnapshot(target); + this.cancel(); } } } diff --git a/src/main/java/com/gmail/nossr50/util/MetadataConstants.java b/src/main/java/com/gmail/nossr50/util/MetadataConstants.java index af8599922..e50568d6f 100644 --- a/src/main/java/com/gmail/nossr50/util/MetadataConstants.java +++ b/src/main/java/com/gmail/nossr50/util/MetadataConstants.java @@ -17,10 +17,9 @@ public class MetadataConstants { MetadataConstants.METADATA_KEY_PLAYER_BRED_MOB, MetadataConstants.METADATA_KEY_PLAYER_TAMED_MOB, MetadataConstants.METADATA_KEY_EXPLOITED_ENDERMEN, - MetadataConstants.METADATA_KEY_CUSTOM_NAME, + MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, MetadataConstants.METADATA_KEY_RUPTURE, MetadataConstants.METADATA_KEY_EXPLOSION_FROM_RUPTURE, - MetadataConstants.METADATA_KEY_OLD_NAME_KEY, MetadataConstants.METADATA_KEY_DODGE_TRACKER ); @@ -41,7 +40,8 @@ public class MetadataConstants { public static final @NotNull String METADATA_KEY_CUSTOM_DAMAGE = "mcMMO: Custom Damage"; public static final @NotNull String METADATA_KEY_TRAVELING_BLOCK = "mcMMO: Traveling Block"; public static final @NotNull String METADATA_KEY_TRACKED_TNT = "mcMMO: Tracked TNT"; - public static final @NotNull String METADATA_KEY_NAME_VISIBILITY = "mcMMO: Name Visibility"; + /** Single key storing a {@link com.gmail.nossr50.datatypes.meta.HealthbarSnapshot} before mcMMO applies a healthbar display. */ + public static final @NotNull String METADATA_KEY_HEALTHBAR_SNAPSHOT = "mcmmo_healthbar_snapshot"; public static final @NotNull String METADATA_KEY_INF_ARROW = "mcMMO: Infinite Arrow"; public static final @NotNull String METADATA_KEY_TRACKED_ARROW = "mcMMO: Tracked Arrow"; public static final @NotNull String METADATA_KEY_BOW_FORCE = "mcMMO: Bow Force"; @@ -62,8 +62,7 @@ public class MetadataConstants { public static final @NotNull String METADATA_KEY_PLAYER_TAMED_MOB = "mcmmo_player_tamed_mob"; public static final @NotNull String METADATA_KEY_VILLAGER_TRADE_ORIGIN_ITEM = "mcmmo_villager_trade_origin_item"; public static final @NotNull String METADATA_KEY_EXPLOITED_ENDERMEN = "mcmmo_exploited_endermen"; - public static final @NotNull String METADATA_KEY_CUSTOM_NAME = "mcmmo_custom_name"; - public static final @NotNull String METADATA_KEY_OLD_NAME_KEY = "mcmmo_old_name"; + public static final @NotNull String METADATA_KEY_RUPTURE = "mcmmo_rupture"; public static final byte SIMPLE_FLAG_VALUE = (byte) 0x1; public static FixedMetadataValue MCMMO_METADATA_VALUE; diff --git a/src/main/java/com/gmail/nossr50/util/MobHealthbarUtils.java b/src/main/java/com/gmail/nossr50/util/MobHealthbarUtils.java index 0354caef6..7d56f2d3b 100644 --- a/src/main/java/com/gmail/nossr50/util/MobHealthbarUtils.java +++ b/src/main/java/com/gmail/nossr50/util/MobHealthbarUtils.java @@ -4,16 +4,18 @@ import static com.gmail.nossr50.listeners.EntityListener.isArmorStandEntity; import static com.gmail.nossr50.listeners.EntityListener.isMannequinEntity; import com.gmail.nossr50.datatypes.MobHealthbarType; -import com.gmail.nossr50.datatypes.meta.OldName; +import com.gmail.nossr50.datatypes.meta.HealthbarSnapshot; import com.gmail.nossr50.mcMMO; import com.gmail.nossr50.runnables.MobHealthDisplayUpdaterTask; -import com.gmail.nossr50.util.text.StringUtils; +import java.util.List; import org.bukkit.ChatColor; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.event.entity.EntityDamageByEntityEvent; -import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class MobHealthbarUtils { private MobHealthbarUtils() { @@ -22,18 +24,30 @@ public final class MobHealthbarUtils { /** * Fix issues with death messages caused by the mob healthbars. * + *
Not called internally. mcMMO's own fix path is + * {@code PlayerListener.onEntityDamageByEntityHighest}, which calls + * {@link #restoreNameFromSnapshot} on the attacker before the death message fires so + * the message uses the real mob name naturally. That approach is preferred over post-hoc regex + * replacement. + * + *
As a best-effort side effect this method now also calls {@link #restoreNameFromSnapshot} + * on the attacker (when available from the player's last damage cause), so external callers + * that still use this method benefit from both the proactive name restore and the regex + * fallback for any healthbar characters that may have slipped through. + * * @param deathMessage The original death message * @param player The player who died * @return the fixed death message + * @deprecated Prefer proactively calling {@link #restoreNameFromSnapshot} on the attacker + * before the death message fires rather than fixing the message string after the fact. */ + @Deprecated public static String fixDeathMessage(String deathMessage, Player player) { - EntityDamageEvent lastDamageCause = player.getLastDamageCause(); - String replaceString = lastDamageCause instanceof EntityDamageByEntityEvent - ? StringUtils.getPrettyEntityTypeString( - ((EntityDamageByEntityEvent) lastDamageCause).getDamager().getType()) : "a mob"; - - return deathMessage.replaceAll("(?:(§(?:[0-9A-FK-ORa-fk-or]))*(?:[❤■]{1,10})){1,2}", - replaceString); + if (player.getLastDamageCause() instanceof EntityDamageByEntityEvent edbe + && edbe.getDamager() instanceof LivingEntity attacker) { + restoreNameFromSnapshot(attacker); + } + return deathMessage; } /** @@ -61,53 +75,101 @@ public final class MobHealthbarUtils { return; } - final String originalName = target.getName(); - String oldName = target.getCustomName(); + // Capture the pre-healthbar name state. null is preserved as null — never coerced to "" + // so that restoration calls setCustomName(null) and correctly clears the custom name slot. + final @Nullable String previousCustomName = target.getCustomName(); + final boolean previousNameVisible = target.isCustomNameVisible(); - /* - * Store the name in metadata - */ - if (target.getMetadata(MetadataConstants.METADATA_KEY_OLD_NAME_KEY).isEmpty()) { - target.setMetadata(MetadataConstants.METADATA_KEY_OLD_NAME_KEY, - new OldName(originalName, plugin)); - } - - if (oldName == null) { - oldName = ""; - } - - boolean oldNameVisible = target.isCustomNameVisible(); - String newName = createHealthDisplay(mcMMO.p.getGeneralConfig().getMobHealthbarDefault(), + final String newName = createHealthDisplay(mcMMO.p.getGeneralConfig().getMobHealthbarDefault(), target, damage); target.setCustomName(newName); target.setCustomNameVisible(true); - int displayTime = mcMMO.p.getGeneralConfig().getMobHealthbarTime(); + final int displayTime = mcMMO.p.getGeneralConfig().getMobHealthbarTime(); - if (displayTime != -1) { - boolean updateName = !ChatColor.stripColor(oldName) - .equalsIgnoreCase(ChatColor.stripColor(newName)); + final long now = System.currentTimeMillis(); + final long displayTimeMs = (long) displayTime * 1000L; + final long initialDelayTicks = (long) displayTime * Misc.TICK_CONVERSION_FACTOR; - if (updateName) { - target.setMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME, - new FixedMetadataValue(mcMMO.p, oldName)); - target.setMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY, - new FixedMetadataValue(mcMMO.p, oldNameVisible)); - } else if (!target.hasMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME)) { - target.setMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME, - new FixedMetadataValue(mcMMO.p, "")); - target.setMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY, - new FixedMetadataValue(mcMMO.p, false)); - } + if (!target.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT)) { + // First hit: capture original name and schedule ONE self-managing cleanup task. + // The task polls every few ticks after the initial delay and extends itself whenever + // the mob is hit again before the display window expires. + target.setMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, + new FixedMetadataValue(plugin, + new HealthbarSnapshot(previousCustomName, previousNameVisible, now))); mcMMO.p.getFoliaLib().getScheduler() - .runAtEntityLater(target, new MobHealthDisplayUpdaterTask(target), - (long) displayTime - * Misc.TICK_CONVERSION_FACTOR); // Clear health display after 3 seconds + .runAtEntityTimer(target, + new MobHealthDisplayUpdaterTask(target, displayTimeMs), + initialDelayTicks, + MobHealthDisplayUpdaterTask.POLL_INTERVAL_TICKS); + } else { + // Re-hit: refresh lastHitMs so the existing task extends the display window. + // Original name fields are preserved from the first-hit snapshot — overwriting them + // here would replace the real name with the current healthbar string. + final HealthbarSnapshot existing = getHealthbarSnapshot(target); + if (existing != null) { + target.setMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, + new FixedMetadataValue(plugin, + new HealthbarSnapshot( + existing.previousCustomName(), + existing.previousNameVisible(), + now))); + } } } + /** + * Restores a mob's custom name and name-visibility to their pre-healthbar state using the + * {@link com.gmail.nossr50.datatypes.meta.HealthbarSnapshot} stored in entity metadata, + * then removes the snapshot key. + * + *
This is the single canonical restore path. All callers — the display timer task,
+ * the lethal-damage handler, and the entity-cleanup path — must use this method rather than
+ * duplicating the check-restore-remove pattern.
+ *
+ * @param entity the entity to restore
+ */
+ public static void restoreNameFromSnapshot(@NotNull LivingEntity entity) {
+ final List A controllable {@link LongSupplier} is injected in place of {@code System::currentTimeMillis}
+ * so each test can advance the clock without sleeping. The task's three natural exit conditions are
+ * each exercised independently: entity invalid, snapshot absent, and display time elapsed.
+ */
+class MobHealthDisplayUpdaterTaskTest extends MMOTestEnvironment {
+ private static final Logger logger = Logger.getLogger(MobHealthDisplayUpdaterTaskTest.class.getName());
+ private static final long DISPLAY_TIME_MS = 3_000L;
+
+ private LivingEntity target;
+ private final AtomicLong fakeTime = new AtomicLong();
+ private LongSupplier timeSource;
+
+ @BeforeEach
+ void setUp() throws InvalidSkillException {
+ mockBaseEnvironment(logger);
+ target = Mockito.mock(LivingEntity.class);
+ fakeTime.set(1_000L);
+ timeSource = fakeTime::get;
+ }
+
+ @AfterEach
+ void tearDown() {
+ cleanUpStaticMocks();
+ }
+
+ private void stubSnapshotPresent(final HealthbarSnapshot snapshot) {
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+ when(target.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+ }
+
+ private void stubSnapshotAbsent() {
+ when(target.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(Collections.emptyList());
+ }
+
+ @Nested
+ class WhenEntityInvalid {
+
+ @BeforeEach
+ void arrange() {
+ when(target.isValid()).thenReturn(false);
+ }
+
+ @Test
+ void doesNotRestoreName() {
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target, never()).setCustomName(any());
+ verify(target, never()).setCustomNameVisible(anyBoolean());
+ }
+
+ @Test
+ void doesNotRemoveMetadata() {
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target, never()).removeMetadata(any(), any());
+ }
+ }
+
+ @Nested
+ class WhenNoSnapshot {
+
+ @BeforeEach
+ void arrange() {
+ when(target.isValid()).thenReturn(true);
+ stubSnapshotAbsent();
+ }
+
+ @Test
+ void doesNotRestoreName() {
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target, never()).setCustomName(any());
+ verify(target, never()).setCustomNameVisible(anyBoolean());
+ }
+ }
+
+ @Nested
+ class WhenDisplayTimeNotElapsed {
+
+ @Test
+ void doesNotRestoreNameWhenHalfTimeElapsed() {
+ // lastHitMs = 0, fakeTime = DISPLAY_TIME_MS/2 — not enough time has passed
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Boss", true, 0L);
+ fakeTime.set(DISPLAY_TIME_MS / 2);
+
+ when(target.isValid()).thenReturn(true);
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target, never()).setCustomName(any());
+ verify(target, never()).removeMetadata(any(), any());
+ }
+
+ @Test
+ void doesNotRestoreOnMillisecondBeforeBoundary() {
+ // elapsed = DISPLAY_TIME_MS - 1 — one millisecond short of the trigger threshold
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Boss", true, 0L);
+ fakeTime.set(DISPLAY_TIME_MS - 1);
+
+ when(target.isValid()).thenReturn(true);
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target, never()).setCustomName(any());
+ }
+ }
+
+ @Nested
+ class WhenDisplayTimeElapsed {
+
+ @BeforeEach
+ void arrangeEntityValid() {
+ when(target.isValid()).thenReturn(true);
+ }
+
+ @Test
+ void restoresNamedMob() {
+ // lastHitMs = 0, fakeTime = DISPLAY_TIME_MS → elapsed = 3000ms >= 3000ms
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("BossZombie", true, 0L);
+ fakeTime.set(DISPLAY_TIME_MS);
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target).setCustomName("BossZombie");
+ verify(target).setCustomNameVisible(true);
+ verify(target).removeMetadata(
+ eq(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT), eq(mcMMO.p));
+ }
+
+ @Test
+ void restoresNullName() {
+ // Vanilla mob with no custom name — null must be restored, not ""
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot(null, false, 0L);
+ fakeTime.set(DISPLAY_TIME_MS);
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target).setCustomName((String) null);
+ verify(target).setCustomNameVisible(false);
+ verify(target).removeMetadata(
+ eq(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT), eq(mcMMO.p));
+ }
+
+ @Test
+ void exactBoundaryTriggersRestore() {
+ // elapsed exactly equals displayTimeMs — boundary must trigger restore (>=, not >)
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Mob", false, 0L);
+ fakeTime.set(DISPLAY_TIME_MS); // exactly at boundary
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ verify(target).setCustomName("Mob");
+ }
+
+ @Test
+ void doesNotRestoreWhenReHitRefreshedTimestamp() {
+ // Simulates a re-hit that updated lastHitMs to fakeTime - 1 (still not elapsed)
+ final long reHitMs = fakeTime.get() - 1; // elapsed = 1ms < DISPLAY_TIME_MS
+ final HealthbarSnapshot refreshedSnapshot =
+ new HealthbarSnapshot("Boss", true, reHitMs);
+ stubSnapshotPresent(refreshedSnapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+ task.run();
+
+ // lastHitMs was just refreshed — display window extended, no restore yet
+ verify(target, never()).setCustomName(any());
+ }
+ }
+
+ @Nested
+ class PollCountInvariants {
+
+ @Test
+ void pollIntervalTicksIsPositive() {
+ assertThat(MobHealthDisplayUpdaterTask.POLL_INTERVAL_TICKS).isGreaterThan(0);
+ }
+
+ @Test
+ void stalePollLimitIsSensible() {
+ assertThat(MobHealthDisplayUpdaterTask.STALE_POLL_LIMIT).isGreaterThan(0);
+ }
+ }
+
+ @Nested
+ class StalePollGuard {
+
+ @Test
+ void restoresAfterStalePollLimit() {
+ // lastHitMs = 0; fakeTime = DISPLAY_TIME_MS - 1 so elapsed never reaches displayTimeMs.
+ // After STALE_POLL_LIMIT consecutive idle polls the failsafe must restore.
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Boss", true, 0L);
+ fakeTime.set(DISPLAY_TIME_MS - 1);
+
+ when(target.isValid()).thenReturn(true);
+ stubSnapshotPresent(snapshot);
+
+ final MobHealthDisplayUpdaterTask task =
+ new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
+
+ // First call initialises lastObservedLastHitMs — stale counting starts on call 2.
+ // Stale fires when stalePollCount reaches STALE_POLL_LIMIT, which happens on
+ // call (STALE_POLL_LIMIT + 1).
+ for (int i = 0; i < MobHealthDisplayUpdaterTask.STALE_POLL_LIMIT; i++) {
+ task.run();
+ }
+ verify(target, never()).setCustomName(any()); // not yet
+
+ task.run(); // this call pushes stalePollCount to STALE_POLL_LIMIT
+ verify(target).setCustomName("Boss");
+ verify(target).removeMetadata(
+ eq(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT), eq(mcMMO.p));
+ }
+
+ @Test
+ void resetsCounterOnLastHitMsChange() {
+ // Run 50 idle polls, then simulate a re-hit (new lastHitMs), run 50 more.
+ // Total: 100 calls, but counter resets at the re-hit, so stale limit is never reached.
+ fakeTime.set(DISPLAY_TIME_MS - 1); // elapsed always < displayTimeMs
+ when(target.isValid()).thenReturn(true);
+
+ final HealthbarSnapshot firstSnapshot = new HealthbarSnapshot("Boss", true, 0L);
+ final HealthbarSnapshot reHitSnapshot = new HealthbarSnapshot("Boss", true, 500L);
+ final AtomicReference These tests cover all code paths that touch the single
+ * {@link MetadataConstants#METADATA_KEY_HEALTHBAR_SNAPSHOT} key: writing on first hit,
+ * the re-hit guard that prevents overwriting the original name, null-name preservation,
+ * and the canonical restore path used by the timer, death handler, and entity cleanup.
+ */
+class MobHealthbarUtilsTest extends MMOTestEnvironment {
+ private static final Logger logger = getLogger(MobHealthbarUtilsTest.class.getName());
+
+ private LivingEntity entity;
+
+ @BeforeEach
+ void setUp() throws InvalidSkillException {
+ mockBaseEnvironment(logger);
+ entity = Mockito.mock(LivingEntity.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ cleanUpStaticMocks();
+ }
+
+ @Nested
+ class RestoreNameFromSnapshot {
+
+ @Test
+ void restoresNamedMobCorrectly() {
+ // Given – mob had a real custom name before healthbar was applied
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Fido", true, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ MobHealthbarUtils.restoreNameFromSnapshot(entity);
+
+ // Then
+ verify(entity).setCustomName("Fido");
+ verify(entity).setCustomNameVisible(true);
+ verify(entity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void restoresNullWhenMobHadNoCustomName() {
+ // Given – vanilla mob: null must come back as null, not ""
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot(null, false, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ MobHealthbarUtils.restoreNameFromSnapshot(entity);
+
+ // Then – null is passed directly, not ""
+ verify(entity).setCustomName((String) null);
+ verify(entity).setCustomNameVisible(false);
+ verify(entity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void doesNothingWhenNoSnapshotPresent() {
+ // Given – entity was never touched by the healthbar system
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(false);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(Collections.emptyList());
+
+ // When
+ MobHealthbarUtils.restoreNameFromSnapshot(entity);
+
+ // Then – no name change, no metadata removal
+ verify(entity, never()).setCustomName(Mockito.any());
+ verify(entity, never()).setCustomNameVisible(Mockito.anyBoolean());
+ verify(entity, never()).removeMetadata(
+ MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, mcMMO.p);
+ }
+
+ @Test
+ void restoresNameVisibilityFalseCorrectly() {
+ // Given – mob had a custom name but it was not visible
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("HiddenName", false, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ MobHealthbarUtils.restoreNameFromSnapshot(entity);
+
+ // Then
+ verify(entity).setCustomName("HiddenName");
+ verify(entity).setCustomNameVisible(false);
+ }
+ }
+
+ @Nested
+ class HasHealthbarSnapshot {
+
+ @Test
+ void returnsTrueWhenSnapshotPresent() {
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+
+ assertThat(MobHealthbarUtils.hasHealthbarSnapshot(entity)).isTrue();
+ }
+
+ @Test
+ void returnsFalseWhenSnapshotAbsent() {
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(false);
+
+ assertThat(MobHealthbarUtils.hasHealthbarSnapshot(entity)).isFalse();
+ }
+ }
+
+ @Nested
+ class GetHealthbarSnapshot {
+
+ @Test
+ void returnsSnapshotWhenPresent() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Fido", true, 12345L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ final HealthbarSnapshot result = MobHealthbarUtils.getHealthbarSnapshot(entity);
+ assertThat(result).isNotNull();
+ assertThat(result.previousCustomName()).isEqualTo("Fido");
+ assertThat(result.previousNameVisible()).isTrue();
+ assertThat(result.lastHitMs()).isEqualTo(12345L);
+ }
+
+ @Test
+ void returnsNullWhenSnapshotAbsent() {
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(Collections.emptyList());
+
+ assertThat(MobHealthbarUtils.getHealthbarSnapshot(entity)).isNull();
+ }
+ }
+
+ @Nested
+ class HealthbarSnapshotRecord {
+
+ @Test
+ void preservesNullName() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot(null, true, 0L);
+ assertThat(snapshot.previousCustomName()).isNull();
+ assertThat(snapshot.previousNameVisible()).isTrue();
+ }
+
+ @Test
+ void preservesNonNullName() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("BossZombie", false, 0L);
+ assertThat(snapshot.previousCustomName()).isEqualTo("BossZombie");
+ assertThat(snapshot.previousNameVisible()).isFalse();
+ }
+
+ @Test
+ void preservesLastHitMs() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Mob", true, 99999L);
+ assertThat(snapshot.lastHitMs()).isEqualTo(99999L);
+ }
+
+ @Test
+ void equalityHoldsForSameValues() {
+ final HealthbarSnapshot first = new HealthbarSnapshot("Mob", true, 1000L);
+ final HealthbarSnapshot second = new HealthbarSnapshot("Mob", true, 1000L);
+ // Records provide value-based equals by default
+ assertThat(first).isEqualTo(second);
+ }
+
+ @Test
+ void inequalityOnDifferentName() {
+ final HealthbarSnapshot original = new HealthbarSnapshot("Mob", true, 0L);
+ final HealthbarSnapshot differentName = new HealthbarSnapshot("OtherMob", true, 0L);
+ assertThat(original).isNotEqualTo(differentName);
+ }
+
+ @Test
+ void inequalityOnDifferentVisibility() {
+ final HealthbarSnapshot nameVisible = new HealthbarSnapshot("Mob", true, 0L);
+ final HealthbarSnapshot nameHidden = new HealthbarSnapshot("Mob", false, 0L);
+ assertThat(nameVisible).isNotEqualTo(nameHidden);
+ }
+
+ @Test
+ void inequalityOnDifferentLastHitMs() {
+ final HealthbarSnapshot firstHit = new HealthbarSnapshot("Mob", true, 1000L);
+ final HealthbarSnapshot reHit = new HealthbarSnapshot("Mob", true, 2000L);
+ assertThat(firstHit).isNotEqualTo(reHit);
+ }
+
+ @Test
+ void nullAndEmptyStringAreDistinct() {
+ // This is the core null-vs-"" correctness guarantee.
+ // A snapshot storing null must never be equal to one storing "".
+ final HealthbarSnapshot nullName = new HealthbarSnapshot(null, false, 0L);
+ final HealthbarSnapshot emptyName = new HealthbarSnapshot("", false, 0L);
+ assertThat(nullName).isNotEqualTo(emptyName);
+ assertThat(nullName.previousCustomName()).isNull();
+ assertThat(emptyName.previousCustomName()).isEqualTo("");
+ }
+ }
+
+ @Nested
+ class ReHitGuard {
+
+ @Test
+ void reHitUpdatesLastHitMsButPreservesOriginalName() {
+ // Arrange – snapshot from first hit with real name
+ final long firstHitTime = 1000L;
+ final HealthbarSnapshot firstHitSnapshot =
+ new HealthbarSnapshot("Fido", true, firstHitTime);
+ final FixedMetadataValue firstHitMeta =
+ new FixedMetadataValue(mcMMO.p, firstHitSnapshot);
+
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(firstHitMeta));
+
+ // Act – simulate what handleMobHealthbars does on re-hit:
+ // read existing snapshot, build a new one with updated lastHitMs only
+ final long reHitTime = 2000L;
+ final HealthbarSnapshot existing = MobHealthbarUtils.getHealthbarSnapshot(entity);
+ assertThat(existing).isNotNull();
+
+ final HealthbarSnapshot reHitSnapshot = new HealthbarSnapshot(
+ existing.previousCustomName(),
+ existing.previousNameVisible(),
+ reHitTime);
+
+ // Assert – original name fields preserved, only timestamp updated
+ assertThat(reHitSnapshot.previousCustomName()).isEqualTo("Fido");
+ assertThat(reHitSnapshot.previousNameVisible()).isTrue();
+ assertThat(reHitSnapshot.lastHitMs()).isEqualTo(reHitTime);
+ // Different from first snapshot only in lastHitMs
+ assertThat(reHitSnapshot).isNotEqualTo(firstHitSnapshot);
+ }
+
+ /**
+ * Verifies the snapshot guard: on first hit the snapshot does not yet exist,
+ * so the write condition evaluates to true.
+ */
+ @Test
+ void writtenOnFirstHit() {
+ // Arrange – no snapshot exists yet
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(false);
+
+ // Act – the write condition from handleMobHealthbars:
+ final boolean shouldWrite =
+ !entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
+
+ // Assert – write should proceed
+ assertThat(shouldWrite).isTrue();
+ }
+
+ /**
+ * Verifies that a re-hit must NOT schedule a new cleanup task (only update lastHitMs).
+ * The existing task remains in flight and will check elapsed time on its next poll.
+ */
+ @Test
+ void reHitDoesNotScheduleNewTask() {
+ // On re-hit, hasMetadata returns true — the task-scheduling branch is skipped.
+ when(entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(new FixedMetadataValue(mcMMO.p,
+ new HealthbarSnapshot("Fido", true, 1000L))));
+
+ final boolean snapshotAlreadyPresent =
+ entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
+
+ // No runAtEntityTimer call should be made — verified by confirming the condition
+ // that gates scheduling is false
+ assertThat(snapshotAlreadyPresent).isTrue(); // gate condition → skip scheduling
+ verify(entity, never()).setMetadata(
+ Mockito.eq(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT),
+ Mockito.any());
+ }
+ }
+}
diff --git a/src/test/java/com/gmail/nossr50/util/skills/CombatUtilsTest.java b/src/test/java/com/gmail/nossr50/util/skills/CombatUtilsTest.java
new file mode 100644
index 000000000..3ee32857f
--- /dev/null
+++ b/src/test/java/com/gmail/nossr50/util/skills/CombatUtilsTest.java
@@ -0,0 +1,232 @@
+package com.gmail.nossr50.util.skills;
+
+import static java.util.logging.Logger.getLogger;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.gmail.nossr50.MMOTestEnvironment;
+import com.gmail.nossr50.api.exceptions.InvalidSkillException;
+import com.gmail.nossr50.datatypes.meta.HealthbarSnapshot;
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.util.MetadataConstants;
+import java.util.Collections;
+import java.util.List;
+import org.bukkit.entity.LivingEntity;
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.bukkit.metadata.FixedMetadataValue;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+class CombatUtilsTest extends MMOTestEnvironment {
+ private static final java.util.logging.Logger logger = getLogger(
+ CombatUtilsTest.class.getName());
+
+ private LivingEntity targetEntity;
+
+ @BeforeEach
+ void setUp() throws InvalidSkillException {
+ mockBaseEnvironment(logger);
+ targetEntity = Mockito.mock(LivingEntity.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ cleanUpStaticMocks();
+ }
+
+ @Nested
+ class FixNames {
+
+ @Test
+ void restoresNameAndVisibilityFromSnapshot() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Boss Slime", true, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ CombatUtils.fixNames(targetEntity);
+
+ verify(targetEntity).setCustomName("Boss Slime");
+ verify(targetEntity).setCustomNameVisible(true);
+ verify(targetEntity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void restoresNullNameWhenSnapshotHadNoCustomName() {
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot(null, false, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ CombatUtils.fixNames(targetEntity);
+
+ verify(targetEntity).setCustomName((String) null);
+ verify(targetEntity).setCustomNameVisible(false);
+ verify(targetEntity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void doesNothingWhenNoSnapshotExists() {
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(false);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(Collections.emptyList());
+
+ CombatUtils.fixNames(targetEntity);
+
+ verify(targetEntity, never()).setCustomName(Mockito.any());
+ verify(targetEntity, never()).setCustomNameVisible(Mockito.anyBoolean());
+ verify(targetEntity, never()).removeMetadata(
+ MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, mcMMO.p);
+ }
+ }
+
+ @Nested
+ class RestoreMobNameIfLethal {
+
+ @Test
+ void restoresWhenLethalAndSnapshotPresent() {
+ // Given – damage will kill the entity, snapshot is present
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Named Mob", true, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ when(event.getEntity()).thenReturn(targetEntity);
+ when(event.getFinalDamage()).thenReturn(10.0);
+ when(targetEntity.getHealth()).thenReturn(5.0); // damage > health → lethal
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then
+ verify(targetEntity).setCustomName("Named Mob");
+ verify(targetEntity).setCustomNameVisible(true);
+ verify(targetEntity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void skipsWhenNonLethal() {
+ // Given – entity survives
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ when(event.getEntity()).thenReturn(targetEntity);
+ when(event.getFinalDamage()).thenReturn(3.0);
+ when(targetEntity.getHealth()).thenReturn(10.0); // damage < health → survives
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then – snapshot never consulted
+ verify(targetEntity, never()).hasMetadata(
+ MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
+ verify(targetEntity, never()).setCustomName(Mockito.any());
+ verify(targetEntity, never()).setCustomNameVisible(Mockito.anyBoolean());
+ }
+
+ @Test
+ void skipsWhenNoSnapshot() {
+ // Given – lethal hit but entity was never given a healthbar
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ when(event.getEntity()).thenReturn(targetEntity);
+ when(event.getFinalDamage()).thenReturn(20.0);
+ when(targetEntity.getHealth()).thenReturn(5.0); // lethal
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(false);
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then
+ verify(targetEntity, never()).setCustomName(Mockito.any());
+ verify(targetEntity, never()).setCustomNameVisible(Mockito.anyBoolean());
+ verify(targetEntity, never()).removeMetadata(
+ MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, mcMMO.p);
+ }
+
+ @Test
+ void skipsNonLivingEntity() {
+ // Given – event entity is not a LivingEntity
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ final org.bukkit.entity.Entity nonLivingEntity = Mockito.mock(
+ org.bukkit.entity.Entity.class);
+ when(event.getEntity()).thenReturn(nonLivingEntity);
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then – no living-entity interaction at all
+ verify(targetEntity, never()).setCustomName(Mockito.any());
+ verify(targetEntity, never()).setCustomNameVisible(Mockito.anyBoolean());
+ }
+
+ @Test
+ void restoresNullCustomNameCorrectly() {
+ // Given – entity had no custom name before healthbar was applied
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot(null, false, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ when(event.getEntity()).thenReturn(targetEntity);
+ when(event.getFinalDamage()).thenReturn(50.0);
+ when(targetEntity.getHealth()).thenReturn(1.0); // lethal
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then – null name is restored, not ""
+ verify(targetEntity).setCustomName((String) null);
+ verify(targetEntity).setCustomNameVisible(false);
+ verify(targetEntity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+
+ @Test
+ void exactLethalBoundaryTriggersRestore() {
+ // Given – final damage exactly equals health (boundary: exactly lethal)
+ final HealthbarSnapshot snapshot = new HealthbarSnapshot("Boundary Mob", false, 0L);
+ final FixedMetadataValue metaValue = new FixedMetadataValue(mcMMO.p, snapshot);
+
+ final EntityDamageEvent event = Mockito.mock(EntityDamageEvent.class);
+ when(event.getEntity()).thenReturn(targetEntity);
+ when(event.getFinalDamage()).thenReturn(10.0);
+ when(targetEntity.getHealth()).thenReturn(10.0); // exactly equal → lethal
+
+ when(targetEntity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(true);
+ when(targetEntity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
+ .thenReturn(List.of(metaValue));
+
+ // When
+ CombatUtils.restoreMobNameIfLethal(event);
+
+ // Then – should restore (damage >= health)
+ verify(targetEntity).setCustomName("Boundary Mob");
+ verify(targetEntity).removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT,
+ mcMMO.p);
+ }
+ }
+}
+