Fixed a bunch of mob health display bugs

Fixes #5240 Fixes #5127 Fixes #3780 Fixes #5255
This commit is contained in:
nossr50
2026-05-03 18:18:01 -07:00
parent bd7ac8c655
commit dbae9c6bb8
14 changed files with 1123 additions and 114 deletions

View File

@ -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)

View File

@ -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 */

View File

@ -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.
*
* <p>{@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
) {
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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,

View File

@ -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.
*
* <p>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();
}
}
}

View File

@ -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;

View File

@ -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.
*
* <p><b>Not called internally.</b> mcMMO's own fix path is
* {@code PlayerListener.onEntityDamageByEntityHighest}, which calls
* {@link #restoreNameFromSnapshot} on the attacker <em>before</em> the death message fires so
* the message uses the real mob name naturally. That approach is preferred over post-hoc regex
* replacement.
*
* <p>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.
*
* <p>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<MetadataValue> meta =
entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
if (meta.isEmpty()) {
return;
}
final HealthbarSnapshot snapshot = (HealthbarSnapshot) meta.get(0).value();
// Restore null as null — setCustomName(null) correctly clears the slot.
entity.setCustomName(snapshot.previousCustomName());
entity.setCustomNameVisible(snapshot.previousNameVisible());
entity.removeMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT, mcMMO.p);
}
/**
* Returns {@code true} if this entity currently has an active healthbar snapshot, meaning
* mcMMO has replaced its custom name with a healthbar and the restore has not yet fired.
*
* @param entity the entity to check
* @return true if a snapshot is present
*/
public static boolean hasHealthbarSnapshot(@NotNull LivingEntity entity) {
return entity.hasMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
}
/**
* Returns the active {@link HealthbarSnapshot} for this entity, or {@code null} if none
* exists. Prefer {@link #hasHealthbarSnapshot} when only existence needs to be checked.
*
* @param entity the entity to query
* @return the snapshot, or {@code null} if the entity has no active healthbar display
*/
public static @Nullable HealthbarSnapshot getHealthbarSnapshot(@NotNull LivingEntity entity) {
final List<MetadataValue> meta =
entity.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT);
return meta.isEmpty() ? null : (HealthbarSnapshot) meta.get(0).value();
}
private static String createHealthDisplay(MobHealthbarType mobHealthbarType,
LivingEntity entity, double damage) {
double maxHealth = entity.getMaxHealth();

View File

@ -14,21 +14,11 @@ public class TransientMetadataTools {
}
public void cleanLivingEntityMetadata(@NotNull LivingEntity entity) {
//Since it's not written anywhere, apparently the GC won't touch objects with metadata still present on them
if (entity.hasMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME)) {
entity.setCustomName(
entity.getMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME).get(0)
.asString());
entity.removeMetadata(MetadataConstants.METADATA_KEY_CUSTOM_NAME, pluginRef);
}
//Involved in changing mob names to hearts
if (entity.hasMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY)) {
entity.setCustomNameVisible(
entity.getMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY).get(0)
.asBoolean());
entity.removeMetadata(MetadataConstants.METADATA_KEY_NAME_VISIBILITY, pluginRef);
}
// Restore mob name from healthbar snapshot if one is present. This ensures the entity
// leaves the world with its correct name, not a stale healthbar string.
// Since it's not written anywhere, apparently the GC won't touch objects with metadata
// still present on them.
MobHealthbarUtils.restoreNameFromSnapshot(entity);
//Gets assigned to endermen, potentially doesn't get cleared before this point
if (entity.hasMetadata(MetadataConstants.METADATA_KEY_TRAVELING_BLOCK)) {
@ -38,7 +28,6 @@ public class TransientMetadataTools {
//Cleanup mob metadata
removeMobFlags(entity);
//TODO: This loop has some redundancy, this whole method needs to be rewritten
for (String key : MetadataConstants.MOB_METADATA_KEYS) {
if (entity.hasMetadata(key)) {
entity.removeMetadata(key, pluginRef);

View File

@ -8,7 +8,6 @@ import static com.gmail.nossr50.util.Permissions.canUseSubSkill;
import com.gmail.nossr50.config.experience.ExperienceConfig;
import com.gmail.nossr50.datatypes.experience.XPGainReason;
import com.gmail.nossr50.datatypes.interactions.NotificationType;
import com.gmail.nossr50.datatypes.meta.OldName;
import com.gmail.nossr50.datatypes.player.McMMOPlayer;
import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
import com.gmail.nossr50.datatypes.skills.SubSkillType;
@ -26,6 +25,7 @@ import com.gmail.nossr50.skills.tridents.TridentsManager;
import com.gmail.nossr50.skills.unarmed.UnarmedManager;
import com.gmail.nossr50.util.ItemUtils;
import com.gmail.nossr50.util.MetadataConstants;
import com.gmail.nossr50.util.MobHealthbarUtils;
import com.gmail.nossr50.util.Misc;
import com.gmail.nossr50.util.MobHealthbarUtils;
import com.gmail.nossr50.util.Permissions;
@ -49,9 +49,9 @@ import org.bukkit.entity.Tameable;
import org.bukkit.entity.Trident;
import org.bukkit.entity.Wolf;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.inventory.ItemStack;
import org.bukkit.metadata.MetadataValue;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.projectiles.ProjectileSource;
import org.jetbrains.annotations.NotNull;
@ -755,23 +755,37 @@ public final class CombatUtils {
}
/**
* This cleans up names from displaying in chat as hearts
* Restores a mob's custom name from its healthbar snapshot metadata, if present.
*
* @param entity target entity
* @deprecated Use {@link com.gmail.nossr50.util.MobHealthbarUtils#restoreNameFromSnapshot}
* directly.
*/
@Deprecated
public static void fixNames(@NotNull LivingEntity entity) {
List<MetadataValue> metadataValue = entity.getMetadata(
MetadataConstants.METADATA_KEY_OLD_NAME_KEY);
MobHealthbarUtils.restoreNameFromSnapshot(entity);
}
if (metadataValue.size() <= 0) {
/**
* Restores mob name metadata only when a damage event is lethal and a healthbar snapshot
* exists on the entity.
*
* @param entityDamageEvent the damage event to evaluate
*/
public static void restoreMobNameIfLethal(@NotNull EntityDamageEvent entityDamageEvent) {
if (!(entityDamageEvent.getEntity() instanceof LivingEntity livingEntity)) {
return;
}
OldName oldName = (OldName) metadataValue.get(0);
entity.setCustomName(oldName.asString());
entity.setCustomNameVisible(false);
if (entityDamageEvent.getFinalDamage() < livingEntity.getHealth()) {
return;
}
entity.removeMetadata(MetadataConstants.METADATA_KEY_OLD_NAME_KEY, mcMMO.p);
if (!MobHealthbarUtils.hasHealthbarSnapshot(livingEntity)) {
return;
}
MobHealthbarUtils.restoreNameFromSnapshot(livingEntity);
}
/**

View File

@ -0,0 +1,300 @@
package com.gmail.nossr50.runnables;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
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 java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
import java.util.logging.Logger;
import org.bukkit.entity.LivingEntity;
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;
/**
* Unit tests for {@link MobHealthDisplayUpdaterTask} polling logic.
*
* <p>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<HealthbarSnapshot> current = new AtomicReference<>(firstSnapshot);
when(target.getMetadata(MetadataConstants.METADATA_KEY_HEALTHBAR_SNAPSHOT))
.thenAnswer(inv -> List.of(new FixedMetadataValue(mcMMO.p, current.get())));
final MobHealthDisplayUpdaterTask task =
new MobHealthDisplayUpdaterTask(target, DISPLAY_TIME_MS, timeSource);
for (int i = 0; i < 50; i++) {
task.run();
}
current.set(reHitSnapshot); // simulate re-hit: lastHitMs updated
for (int i = 0; i < 50; i++) {
task.run();
}
// 100 total calls, but the re-hit reset the counter — stale limit not reached
verify(target, never()).setCustomName(any());
}
}
}

View File

@ -0,0 +1,320 @@
package com.gmail.nossr50.util;
import static java.util.logging.Logger.getLogger;
import static org.assertj.core.api.Assertions.assertThat;
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 java.util.Collections;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.entity.LivingEntity;
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;
/**
* Unit tests for {@link MobHealthbarUtils} snapshot business logic.
*
* <p>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());
}
}
}

View File

@ -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);
}
}
}